hive-agent: the agent has no login flow of its own, credentials come from the swarm
This commit is contained in:
parent
52d26dd067
commit
ac3b3cfcd3
9 changed files with 46 additions and 844 deletions
|
|
@ -27,8 +27,8 @@ map of the module tree.
|
|||
- **`turn.rs`** — the turn-loop policy layer: renders the system
|
||||
prompt + MCP config, runs the turn through `hive-runtime`, classifies
|
||||
the outcome, and feeds the event/turn-stats sinks.
|
||||
- **`login.rs` / `login_session.rs`** — first-run and session-resume
|
||||
auth flow for the `claude` CLI.
|
||||
- **`login.rs`** — the Online or NeedsLogin state at boot, and the wait
|
||||
for credentials to arrive (the swarm login stores them).
|
||||
- **`mcp_config.rs`** — renders the per-turn `--mcp-config` /
|
||||
`--allowedTools` blob from the agent's permissions.
|
||||
- **`todos.rs` / `reminders.rs` / `todo_server.rs`** — the harness-local
|
||||
|
|
|
|||
|
|
@ -108,12 +108,9 @@ pub enum LiveEvent {
|
|||
/// harness hit a transport error.
|
||||
TurnEnd { ok: bool, note: Option<String> },
|
||||
/// Harness reachability flipped: `"online"` /
|
||||
/// `"needs_login_idle"` / `"needs_login_in_progress"`. The web UI
|
||||
/// drives the alive badge from this so the operator sees a login
|
||||
/// land (or get revoked) without polling. Session detail
|
||||
/// (`url`/`output`/`finished`) is still served by `/api/state`
|
||||
/// during the short-lived in-progress window — the client
|
||||
/// re-fetches only while that flow is active.
|
||||
/// `"needs_login_idle"`. The web UI drives the alive badge from this
|
||||
/// so the operator sees a login land (or get revoked) without
|
||||
/// polling.
|
||||
StatusChanged { status: String },
|
||||
/// The active claude model changed (a swarm value applied or cleared).
|
||||
/// The web UI updates the chip + the per-turn stats sink will key off this
|
||||
|
|
@ -875,9 +872,9 @@ impl Bus {
|
|||
});
|
||||
}
|
||||
|
||||
/// Broadcast a status flip (online / `needs_login_*` / `rate_limited`).
|
||||
/// Broadcast a status flip (online / `needs_login_idle` / `rate_limited`).
|
||||
/// Called by the bin entry points + `turn::wait_for_login` + the
|
||||
/// `post_login_*` handlers — every site that mutates the
|
||||
/// `/api/logout` handler — every site that mutates the
|
||||
/// `Arc<Mutex<LoginState>>` should also call this so the web UI
|
||||
/// drops its periodic /api/state poll while a turn loop is running.
|
||||
///
|
||||
|
|
@ -900,9 +897,8 @@ impl Bus {
|
|||
self.rate_limited.store(false, Ordering::Relaxed);
|
||||
}
|
||||
// Read the current persisted needs_login so we don't flip it on
|
||||
// statuses that shouldn't touch it (e.g. `needs_login_in_progress`
|
||||
// is a transient mid-flow status; only `needs_login_idle` and
|
||||
// `online` should change the persistent flag).
|
||||
// statuses that shouldn't touch it (e.g. `rate_limited`; only
|
||||
// `needs_login_idle` and `online` should change the persistent flag).
|
||||
//
|
||||
// Known non-atomicity: this is a read-modify-write. Two concurrent
|
||||
// `emit_status` calls could clobber each other's `needs_login`
|
||||
|
|
|
|||
|
|
@ -97,7 +97,8 @@ pub enum LoginState {
|
|||
/// `~/.claude/` has credentials; turn loop is running.
|
||||
Online,
|
||||
/// `~/.claude/` is empty; harness is up, web UI is bound, turn loop is NOT
|
||||
/// running. Operator needs to complete login from the web UI.
|
||||
/// running. The operator logs in from the swarm UI; the credentials then
|
||||
/// arrive from the swarm secret store.
|
||||
NeedsLogin,
|
||||
}
|
||||
|
||||
|
|
@ -378,7 +379,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn has_fresh_credentials_rewrite_after_failure_resumes() {
|
||||
// The operator's `/login/code` flow rewrites the stale file in
|
||||
// A fresh login from the swarm rewrites the stale file in
|
||||
// place; its mtime bumps strictly past the failure baseline.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();
|
||||
|
|
|
|||
|
|
@ -1,313 +0,0 @@
|
|||
//! `claude auth login` driver. Spawns the login command under plain stdio pipes,
|
||||
//! accumulates stdout+stderr in a shared buffer (so the web UI can show
|
||||
//! whatever URL/prompt claude emits), and writes paste-back codes from the
|
||||
//! UI into the child's stdin.
|
||||
//!
|
||||
//! No PTY — we're betting `claude` produces a parseable URL on stdout and
|
||||
//! accepts a code on stdin even when not on a terminal. If it refuses or
|
||||
//! garbles, we'll redo this module backed by `portable-pty` (see PLAN.md
|
||||
//! Phase 8).
|
||||
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, Command};
|
||||
|
||||
const DEFAULT_CMD: &str = "claude";
|
||||
const DEFAULT_ARGS: &[&str] = &["auth", "login"];
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
/// Concatenated stdout+stderr as it streams from the child.
|
||||
output: String,
|
||||
/// First URL-looking substring we saw in the output. Surface this on the
|
||||
/// web UI as the link the operator should open.
|
||||
url: Option<String>,
|
||||
/// Set by `poll` when the child has exited. The web UI uses this to know whether
|
||||
/// the operator can still paste a code.
|
||||
finished: bool,
|
||||
/// Exit status note (e.g. "exited with code 0", "killed by signal 15"),
|
||||
/// shown next to a "finished" badge once the child returns.
|
||||
exit_note: Option<String>,
|
||||
}
|
||||
|
||||
/// A running `claude auth login` subprocess.
|
||||
pub struct LoginSession {
|
||||
child: Mutex<Child>,
|
||||
/// Tokio mutex because we hold the guard across the `write_all().await`
|
||||
/// in `submit_code`. The other locks are blocking-only and stay on
|
||||
/// `std::sync::Mutex`.
|
||||
stdin: tokio::sync::Mutex<Option<ChildStdin>>,
|
||||
state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
impl LoginSession {
|
||||
/// Spawn the login command. The exact binary/args are configurable via
|
||||
/// `HYPERHIVE_LOGIN_CMD` (single string, shell-split into argv); by
|
||||
/// default we run `claude auth login`. Failing to spawn returns an error
|
||||
/// before any state is registered.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if spawning the login command fails, or if the child's
|
||||
/// stdio handles cannot be acquired.
|
||||
pub fn start() -> Result<Self> {
|
||||
let (cmd, args) = resolve_command();
|
||||
tracing::info!(%cmd, ?args, "spawning login session");
|
||||
|
||||
let mut child = Command::new(&cmd)
|
||||
.args(&args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
// `claude` reads $HOME/.claude for the credentials dir. The
|
||||
// harness service env sets HOME to /home/<agent> and the
|
||||
// bind-mount lands the OAuth dir at the same path, so the
|
||||
// child inherits the right HOME without any further wiring
|
||||
// here.
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.with_context(|| format!("spawn `{cmd}`"))?;
|
||||
|
||||
let stdin = child.stdin.take().context("child stdin")?;
|
||||
let stdout = child.stdout.take().context("child stdout")?;
|
||||
let stderr = child.stderr.take().context("child stderr")?;
|
||||
|
||||
let state = Arc::new(Mutex::new(State::default()));
|
||||
tokio::spawn(pump(BufReader::new(stdout), state.clone(), "stdout"));
|
||||
tokio::spawn(pump(BufReader::new(stderr), state.clone(), "stderr"));
|
||||
|
||||
Ok(Self {
|
||||
child: Mutex::new(child),
|
||||
stdin: tokio::sync::Mutex::new(Some(stdin)),
|
||||
state,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `code` (plus a newline) to the child's stdin. Returns an error
|
||||
/// if the stdin has already been closed (e.g. after the child exited or
|
||||
/// after a prior submission consumed it).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the login stdin is already closed, or if writing
|
||||
/// to or flushing the stdin pipe fails.
|
||||
pub async fn submit_code(&self, code: &str) -> Result<()> {
|
||||
let mut guard = self.stdin.lock().await;
|
||||
let stdin = guard.as_mut().context("login stdin already closed")?;
|
||||
let line = format!("{}\n", code.trim());
|
||||
stdin
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.context("write code to claude stdin")?;
|
||||
stdin.flush().await.context("flush claude stdin")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close stdin so claude sees EOF (useful if it's waiting for more input
|
||||
/// after the code submit).
|
||||
pub async fn close_stdin(&self) {
|
||||
let _ = self.stdin.lock().await.take();
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn output(&self) -> String {
|
||||
self.state.lock().unwrap().output.clone()
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn url(&self) -> Option<String> {
|
||||
self.state.lock().unwrap().url.clone()
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn finished(&self) -> bool {
|
||||
self.state.lock().unwrap().finished
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn exit_note(&self) -> Option<String> {
|
||||
self.state.lock().unwrap().exit_note.clone()
|
||||
}
|
||||
|
||||
/// Best-effort: poll the child once and update `finished`/`exit_note`.
|
||||
/// Called by the web UI on each render so the state stays fresh without
|
||||
/// running a dedicated reaper task.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if an internal lock is poisoned.
|
||||
pub fn poll(&self) {
|
||||
let mut child = self.child.lock().unwrap();
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let mut s = self.state.lock().unwrap();
|
||||
s.finished = true;
|
||||
s.exit_note = Some(format!("{status}"));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
let mut s = self.state.lock().unwrap();
|
||||
s.finished = true;
|
||||
s.exit_note = Some(format!("try_wait error: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill the child if it's still running. Idempotent.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn kill(&self) {
|
||||
if let Err(e) = self.child.lock().unwrap().start_kill() {
|
||||
tracing::warn!(error = ?e, "kill login child");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_command() -> (String, Vec<String>) {
|
||||
if let Ok(raw) = std::env::var("HYPERHIVE_LOGIN_CMD") {
|
||||
// Whitespace-only split — no quote handling. Fine for "claude auth login"
|
||||
// style overrides; if we need anything with embedded spaces we'll
|
||||
// switch to shell-words.
|
||||
let mut parts = raw.split_whitespace().map(str::to_owned);
|
||||
if let Some(cmd) = parts.next() {
|
||||
return (cmd, parts.collect());
|
||||
}
|
||||
}
|
||||
(
|
||||
DEFAULT_CMD.into(),
|
||||
DEFAULT_ARGS.iter().map(|s| (*s).to_owned()).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn pump<R: tokio::io::AsyncRead + Unpin>(
|
||||
mut reader: BufReader<R>,
|
||||
state: Arc<Mutex<State>>,
|
||||
tag: &'static str,
|
||||
) {
|
||||
let mut buf = String::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
// read_line breaks on \n; for claude's TUI output that flushes by
|
||||
// line this is fine. If it ever blasts a single un-newlined blob,
|
||||
// we'll miss it until EOF (acceptable for the URL surface — claude
|
||||
// prints the URL on its own line).
|
||||
match reader.read_line(&mut buf).await {
|
||||
// EOF means this pipe closed, not that the child exited: only
|
||||
// `poll` (via `try_wait`) may mark the session finished, or the
|
||||
// next state render would drop a login that is still running.
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let mut s = state.lock().unwrap();
|
||||
if s.url.is_none()
|
||||
&& let Some(url) = extract_url(&buf)
|
||||
{
|
||||
tracing::info!(%url, %tag, "login URL detected");
|
||||
s.url = Some(url);
|
||||
}
|
||||
s.output.push_str(&buf);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, %tag, "login pump read error");
|
||||
state.lock().unwrap().exit_note = Some(format!("pump {tag} error: {e}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the first `https://…` substring on the line, terminating at any
|
||||
/// ASCII whitespace. Good enough for capturing claude's OAuth link without a
|
||||
/// regex dependency.
|
||||
fn extract_url(line: &str) -> Option<String> {
|
||||
let start = line.find("https://")?;
|
||||
let tail = &line[start..];
|
||||
let end = tail
|
||||
.find(|c: char| c.is_ascii_whitespace())
|
||||
.unwrap_or(tail.len());
|
||||
let url = tail[..end].trim_end_matches(['.', ',', ')', ']']);
|
||||
if url.len() > "https://".len() {
|
||||
Some(url.to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper used by the web UI to gate "is there a session running right now"
|
||||
/// without holding both this module's mutex and the `AppState`'s at once.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn drop_if_finished(slot: &Mutex<Option<Arc<LoginSession>>>) {
|
||||
let mut guard = slot.lock().unwrap();
|
||||
if let Some(s) = guard.as_ref() {
|
||||
s.poll();
|
||||
if s.finished() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LoginSession {
|
||||
fn drop(&mut self) {
|
||||
// kill_on_drop on the Command also ensures the child dies, but we
|
||||
// belt-and-brace it in case the runtime detaches.
|
||||
let _ = self.child.lock().unwrap().start_kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{State, extract_url, pump};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::BufReader;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_closed_output_pipe_does_not_finish_the_session() {
|
||||
let state = Arc::new(Mutex::new(State::default()));
|
||||
|
||||
pump(BufReader::new(tokio::io::empty()), state.clone(), "stdout").await;
|
||||
|
||||
assert!(!state.lock().unwrap().finished);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_first_https() {
|
||||
let line = " Go to https://claude.ai/oauth/abc?xyz=1 in your browser.\n";
|
||||
assert_eq!(
|
||||
extract_url(line).as_deref(),
|
||||
Some("https://claude.ai/oauth/abc?xyz=1"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_punctuation_stripped() {
|
||||
let line = "Open https://example.com/abc).\n";
|
||||
assert_eq!(
|
||||
extract_url(line).as_deref(),
|
||||
Some("https://example.com/abc"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_url() {
|
||||
assert_eq!(extract_url("nothing here\n"), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,6 @@ mod events;
|
|||
mod harness_state;
|
||||
mod identity;
|
||||
mod login;
|
||||
mod login_session;
|
||||
mod mcp_config;
|
||||
mod otel_turn_metrics;
|
||||
mod paths;
|
||||
|
|
@ -1141,7 +1140,7 @@ async fn handle_turn_error_recovery<S: Surface>(
|
|||
if matches!(outcome, Err(turn::TurnError::AuthFailed)) {
|
||||
bus.emit_status("needs_login_idle");
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: "API 401 — waiting for re-login via web UI".into(),
|
||||
text: "API 401 — waiting for a re-login (log in from the swarm UI)".into(),
|
||||
});
|
||||
tracing::warn!("auth-failed; parking until re-login");
|
||||
S::requeue_inflight(socket).await;
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ pub enum TurnError {
|
|||
/// The Anthropic API rejected the request with 401 (OAuth session
|
||||
/// expired or revoked). The serve loop should flip the container
|
||||
/// into `needs_login_idle` and stop driving turns until the
|
||||
/// operator re-auths via the per-agent web UI.
|
||||
/// operator logs in again from the swarm UI.
|
||||
AuthFailed,
|
||||
/// `--resume <title>` missed AND the lib's create self-heal also failed to
|
||||
/// resolve the session — "shouldn't happen" (a resume-miss is normally
|
||||
|
|
|
|||
|
|
@ -1,83 +1,20 @@
|
|||
//! Login / logout flow handlers (`/login/*`, `/api/logout`).
|
||||
|
||||
use std::sync::Arc;
|
||||
//! Logout handler (`/api/logout`). The agent has no login flow of its own:
|
||||
//! credentials come from the swarm's secret store (`claude_credentials`).
|
||||
|
||||
use axum::{
|
||||
Form,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::login::LoginState;
|
||||
use crate::login_session::{LoginSession, drop_if_finished};
|
||||
|
||||
use super::{AppState, error_response};
|
||||
|
||||
pub(super) 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 (axum::http::StatusCode::OK, "ok").into_response();
|
||||
}
|
||||
}
|
||||
match LoginSession::start() {
|
||||
Ok(session) => {
|
||||
*state.session.lock().unwrap() = Some(Arc::new(session));
|
||||
// Flip status from needs_login_idle → needs_login_in_progress
|
||||
// so the web UI's badge + polling kick in (polling is still
|
||||
// the right tool for the streaming session output during
|
||||
// the login flow itself; events drop the poll for
|
||||
// *everything else*).
|
||||
state.bus.emit_status("needs_login_in_progress");
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
Err(e) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("login start failed: {e:#}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct CodeForm {
|
||||
code: String,
|
||||
}
|
||||
|
||||
pub(super) 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(StatusCode::CONFLICT, "no login session running");
|
||||
};
|
||||
if let Err(e) = session.submit_code(&form.code).await {
|
||||
return error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("submit code failed: {e:#}"),
|
||||
);
|
||||
}
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
pub(super) 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();
|
||||
}
|
||||
// Back to needs_login_idle (LoginState unchanged, session gone).
|
||||
state.bus.emit_status("needs_login_idle");
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
use super::AppState;
|
||||
|
||||
/// Operator-driven `/logout`: SIGINT claude, delete the credential
|
||||
/// files (via [`crate::login::clear_session`]), flip `LoginState::NeedsLogin`.
|
||||
/// The turn loop's next iteration parks into `wait_for_login` which
|
||||
/// resumes when a fresh credentials file appears via `/login/code`.
|
||||
/// resumes when a fresh credentials file appears (the swarm login stores
|
||||
/// one and `claude_credentials` installs it).
|
||||
/// Always returns 200 with a body describing what happened. See
|
||||
/// `docs/2-handbook/web-ui/agent.md::Per-agent endpoints`
|
||||
/// (the `/api/logout` bullet) for the three-step rationale +
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Per-container HTTP UI. SPA shape: `GET /` returns a static shell;
|
||||
//! `GET /static/*` serves CSS + JS; `GET /api/state` returns the page
|
||||
//! state as JSON; the JS app renders. Live events stream on
|
||||
//! `/events/stream`. Action POSTs (`/login/*`) return either a
|
||||
//! `/events/stream`. Action POSTs return either a
|
||||
//! 303 Redirect (for browsers that submit the form normally) or just
|
||||
//! 200 OK — the JS app re-fetches `/api/state` afterwards.
|
||||
//!
|
||||
|
|
@ -35,7 +35,6 @@ use tower_http::services::ServeDir;
|
|||
|
||||
use crate::events::Bus;
|
||||
use crate::login::LoginState;
|
||||
use crate::login_session::LoginSession;
|
||||
|
||||
/// Deadline for broker-backed fetches on web-UI request paths. The
|
||||
/// page's critical fields (status, turn state, usage) are all
|
||||
|
|
@ -54,7 +53,6 @@ pub type LoginStateCell = Arc<Mutex<LoginState>>;
|
|||
struct AppState {
|
||||
label: String,
|
||||
login: LoginStateCell,
|
||||
session: Arc<Mutex<Option<Arc<LoginSession>>>>,
|
||||
bus: Bus,
|
||||
socket: PathBuf,
|
||||
/// VNC port from the `HIVE_GUI_VNC_PORT` env var at startup.
|
||||
|
|
@ -76,9 +74,6 @@ fn routes() -> Router<AppState> {
|
|||
.route("/api/dashboard-state", get(state::api_dashboard_state))
|
||||
.route("/events/stream", get(stream::events_stream))
|
||||
.route("/events/history", get(stream::events_history))
|
||||
.route("/login/start", post(auth::post_login_start))
|
||||
.route("/login/code", post(auth::post_login_code))
|
||||
.route("/login/cancel", post(auth::post_login_cancel))
|
||||
.route("/api/cancel", post(actions::post_cancel_turn))
|
||||
.route("/api/compact", post(actions::post_compact))
|
||||
.route("/api/new-session", post(actions::post_new_session))
|
||||
|
|
@ -127,7 +122,6 @@ pub async fn serve(
|
|||
let state = AppState {
|
||||
label,
|
||||
login,
|
||||
session: Arc::new(Mutex::new(None)),
|
||||
bus,
|
||||
socket,
|
||||
gui_vnc_port,
|
||||
|
|
@ -541,7 +535,6 @@ mod tests {
|
|||
let state = AppState {
|
||||
label: "test".to_owned(),
|
||||
login: Arc::new(Mutex::new(LoginState::Online)),
|
||||
session: Arc::new(Mutex::new(None)),
|
||||
bus: Bus::new(),
|
||||
socket: crate::paths::harness_dir().join("mcp.sock"),
|
||||
gui_vnc_port: None,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ use serde::Serialize;
|
|||
use swarm_wire::agent_inference::Source;
|
||||
|
||||
use crate::login::LoginState;
|
||||
use crate::login_session::drop_if_finished;
|
||||
|
||||
use super::AppState;
|
||||
|
||||
|
|
@ -13,22 +12,11 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
|
|||
// Capture seq *before* any reads so the dedupe contract is
|
||||
// "events with seq > snapshot.seq are post-snapshot, never missed."
|
||||
let seq = state.bus.current_seq();
|
||||
drop_if_finished(&state.session);
|
||||
let login = *state.login.lock().unwrap();
|
||||
let session_snapshot = state.session.lock().unwrap().clone();
|
||||
let (status, session_view) = match (login, session_snapshot) {
|
||||
(LoginState::Online, _) if state.bus.is_rate_limited() => ("rate_limited", None),
|
||||
(LoginState::Online, _) => ("online", None),
|
||||
(LoginState::NeedsLogin, None) => ("needs_login_idle", None),
|
||||
(LoginState::NeedsLogin, Some(s)) => (
|
||||
"needs_login_in_progress",
|
||||
Some(SessionView {
|
||||
url: s.url(),
|
||||
output: s.output(),
|
||||
finished: s.finished(),
|
||||
exit_note: s.exit_note(),
|
||||
}),
|
||||
),
|
||||
let status = match login {
|
||||
LoginState::Online if state.bus.is_rate_limited() => "rate_limited",
|
||||
LoginState::Online => "online",
|
||||
LoginState::NeedsLogin => "needs_login_idle",
|
||||
};
|
||||
let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT")
|
||||
.ok()
|
||||
|
|
@ -54,7 +42,6 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
|
|||
qualified_label: crate::identity::qualify(&state.label),
|
||||
dashboard_port,
|
||||
status,
|
||||
session: session_view,
|
||||
inbox,
|
||||
turn_state,
|
||||
turn_state_since,
|
||||
|
|
@ -124,10 +111,8 @@ pub(super) struct StateSnapshot {
|
|||
/// when it equals `label`, the page renders the short form unchanged.
|
||||
qualified_label: String,
|
||||
dashboard_port: u16,
|
||||
/// `"online"` | `"rate_limited"` | `"needs_login_idle"` | `"needs_login_in_progress"`.
|
||||
/// `"online"` | `"rate_limited"` | `"needs_login_idle"`.
|
||||
status: &'static str,
|
||||
/// Present when `status == "needs_login_in_progress"`.
|
||||
session: Option<SessionView>,
|
||||
/// Last N messages addressed to this agent, newest-first. Pulled
|
||||
/// from the broker via the per-agent socket on each render.
|
||||
/// Empty on transport failure.
|
||||
|
|
@ -202,16 +187,6 @@ pub(super) struct StateSnapshot {
|
|||
paused: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SessionView {
|
||||
/// First `https://…` claude emitted on stdout, if any.
|
||||
url: Option<String>,
|
||||
/// Accumulated stdout + stderr.
|
||||
output: String,
|
||||
finished: bool,
|
||||
exit_note: Option<String>,
|
||||
}
|
||||
|
||||
/// One navigation link in the agent page header row. The same JSON
|
||||
/// shape appears in both `StateSnapshot.links` (`GET /api/state`,
|
||||
/// per-agent page) and `DashboardState.links` (`GET /api/dashboard-state`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue