support long running session

plus UI reattaching to existing session
This commit is contained in:
lda
2026-07-15 08:16:10 +07:00 Verified
parent 4d793136ba
commit f459bb6914
12 changed files with 751 additions and 148 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ pub use control::{
};
pub use terminals::{
agent_terminal_ws, attach_terminal, close_terminal, create_terminal, get_terminal,
operator_terminal_ws,
list_terminals, operator_terminal_ws,
};
use axum::response::{IntoResponse, Response};
+50 -18
View File
@@ -13,8 +13,8 @@ use wakey_agent::protocol::{
use crate::api::ApiError;
use crate::runtime::terminals::{
TERMINAL_ABSOLUTE_TIMEOUT, TERMINAL_ATTACH_TIMEOUT, TERMINAL_DISCONNECT_GRACE,
TERMINAL_MAX_FRAME_BYTES, TerminalRelayFrame,
TERMINAL_ABSOLUTE_TIMEOUT, TERMINAL_ATTACH_TIMEOUT, TERMINAL_MAX_FRAME_BYTES,
TerminalRelayFrame, TerminalSummary,
};
use crate::runtime::{AppState, SessionEvent};
use crate::state::AuditEventInput;
@@ -142,6 +142,18 @@ pub async fn get_terminal(
}))
}
pub async fn list_terminals(State(state): State<AppState>) -> Json<Vec<TerminalSessionResponse>> {
Json(
state
.terminals
.summaries()
.await
.into_iter()
.map(terminal_response)
.collect(),
)
}
pub async fn attach_terminal(
State(state): State<AppState>,
Path(terminal_id): Path<String>,
@@ -284,11 +296,14 @@ async fn handle_agent_terminal_socket(state: AppState, terminal_id: String, mut
}
}
}
state.terminals.remove(&terminal_id).await;
info!(
terminal_id,
"agent terminal socket detached; session closed"
);
if let Some(agent_id) = state.terminals.detach_agent(&terminal_id).await
&& let Some(session) = state.sessions.read().await.get(&agent_id)
{
let _ = session
.tx
.send(SessionEvent::Message(ServerMessage::SyncTerminalSessions));
}
info!(terminal_id, "agent terminal relay detached");
}
async fn handle_operator_terminal_socket(
@@ -317,10 +332,11 @@ async fn handle_operator_terminal_socket(
}
};
info!(terminal_id, "operator terminal socket attached");
if let Some((agent_id, _, _, _)) = state.terminals.summary(&terminal_id).await {
let summary = state.terminals.summary(&terminal_id).await;
if let Some((agent_id, _, _, _)) = &summary {
append_terminal_audit(
&state,
&agent_id,
agent_id,
&terminal_id,
TerminalAudit {
actor_type: "admin_api",
@@ -334,6 +350,17 @@ async fn handle_operator_terminal_socket(
}
let (mut write, mut read) = socket.split();
if summary.is_some_and(|(_, _, agent_attached, _)| agent_attached) {
let ready = serde_json::to_string(&TerminalControl::Ready)
.expect("terminal ready control serializes");
if send_relay_frame(&mut write, TerminalRelayFrame::Text(ready))
.await
.is_err()
{
state.terminals.detach_operator(&terminal_id).await;
return;
}
}
for frame in replay {
if send_relay_frame(&mut write, frame).await.is_err() {
return;
@@ -367,15 +394,8 @@ async fn handle_operator_terminal_socket(
if explicit_close {
close_registered_terminal(&state, &terminal_id).await;
} else if let Some(detached_at) = state.terminals.detach_operator(&terminal_id).await {
let terminals = state.terminals.clone();
let terminal_id_for_grace = terminal_id.clone();
tokio::spawn(async move {
tokio::time::sleep(TERMINAL_DISCONNECT_GRACE).await;
terminals
.remove_if_still_detached(&terminal_id_for_grace, detached_at)
.await;
});
} else {
state.terminals.detach_operator(&terminal_id).await;
}
info!(
terminal_id,
@@ -383,6 +403,18 @@ async fn handle_operator_terminal_socket(
);
}
fn terminal_response(summary: TerminalSummary) -> TerminalSessionResponse {
TerminalSessionResponse {
websocket_url: operator_ws_path(&summary.terminal_id),
terminal_id: summary.terminal_id,
agent_id: summary.agent_id,
created_at_unix: summary.created_at_unix,
agent_attached: summary.agent_attached,
operator_attached: summary.operator_attached,
attachment_token: None,
}
}
fn agent_relay_frame(message: Message) -> Result<Option<TerminalRelayFrame>, &'static str> {
match message {
Message::Binary(bytes) => Ok(Some(TerminalRelayFrame::Binary(bytes.to_vec()))),
+4 -1
View File
@@ -142,7 +142,10 @@ fn control_api_routes() -> Router<AppState> {
"/api/v1/control/agents/{agent_id}/command",
post(api::run_command),
)
.route("/api/v1/control/terminals", post(api::create_terminal))
.route(
"/api/v1/control/terminals",
get(api::list_terminals).post(api::create_terminal),
)
.route(
"/api/v1/control/terminals/{terminal_id}",
get(api::get_terminal).delete(api::close_terminal),
+190 -34
View File
@@ -4,14 +4,14 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::sync::{Mutex, mpsc};
use uuid::Uuid;
use wakey_agent::protocol::{AgentTerminalSession, TerminalId};
pub const TERMINAL_RELAY_QUEUE: usize = 32;
pub const TERMINAL_MAX_FRAME_BYTES: usize = 64 * 1024;
pub const TERMINAL_REPLAY_BYTES: usize = 256 * 1024;
pub const TERMINAL_MAX_SESSIONS_PER_AGENT: usize = 2;
pub const TERMINAL_ATTACH_TIMEOUT: Duration = Duration::from_secs(10);
pub const TERMINAL_DISCONNECT_GRACE: Duration = Duration::from_secs(15);
pub const TERMINAL_ABSOLUTE_TIMEOUT: Duration = Duration::from_secs(30 * 60);
pub const TERMINAL_ABSOLUTE_TIMEOUT: Duration = Duration::from_secs(12 * 60 * 60);
const TERMINAL_TOMBSTONE_TTL: Duration = Duration::from_secs(5 * 60);
const TERMINAL_MAX_TOMBSTONES: usize = 1024;
@@ -32,6 +32,7 @@ struct TerminalSession {
agent_id: String,
created_at_unix: u64,
expires_at: Instant,
agent_confirmed: bool,
relay_token: Option<String>,
attachment_token: Option<String>,
agent_tx: Option<mpsc::Sender<TerminalRelayFrame>>,
@@ -50,6 +51,15 @@ pub struct CreatedTerminal {
pub created_at_unix: u64,
}
#[derive(Clone, Debug)]
pub struct TerminalSummary {
pub terminal_id: String,
pub agent_id: String,
pub created_at_unix: u64,
pub agent_attached: bool,
pub operator_attached: bool,
}
impl Default for TerminalRegistry {
fn default() -> Self {
Self::new()
@@ -88,6 +98,7 @@ impl TerminalRegistry {
agent_id,
created_at_unix,
expires_at: Instant::now() + TERMINAL_ABSOLUTE_TIMEOUT,
agent_confirmed: false,
relay_token: Some(relay_token.clone()),
attachment_token: Some(attachment_token.clone()),
agent_tx: None,
@@ -146,18 +157,70 @@ impl TerminalRegistry {
}
}
pub async fn remove_agent(&self, agent_id: &str) {
let terminal_ids = {
/// Rebuilds CC's volatile catalog from the PTYs still owned by an agent.
/// Returned credentials tell those workers to establish fresh relay sockets.
pub async fn reconcile_agent_sessions(
&self,
agent_id: &str,
reported: &[AgentTerminalSession],
) -> Vec<(TerminalId, String)> {
let reported_ids = reported
.iter()
.map(|session| session.terminal_id.as_str())
.collect::<std::collections::HashSet<_>>();
let stale_ids = {
let sessions = self.inner.lock().await;
sessions
.iter()
.filter(|(_, session)| session.agent_id == agent_id)
.filter(|(terminal_id, session)| {
session.agent_id == agent_id
&& session.agent_confirmed
&& !reported_ids.contains(terminal_id.as_str())
})
.map(|(terminal_id, _)| terminal_id.clone())
.collect::<Vec<_>>()
};
for terminal_id in terminal_ids {
for terminal_id in stale_ids {
self.remove(&terminal_id).await;
}
let mut credentials = Vec::new();
let mut sessions = self.inner.lock().await;
for reported_session in reported {
let terminal_id = reported_session.terminal_id.as_str().to_string();
let session = sessions
.entry(terminal_id)
.or_insert_with(|| TerminalSession {
agent_id: agent_id.to_string(),
created_at_unix: reported_session.created_at_unix,
expires_at: Instant::now() + TERMINAL_ABSOLUTE_TIMEOUT,
agent_confirmed: true,
relay_token: None,
attachment_token: None,
agent_tx: None,
pending_agent: VecDeque::new(),
pending_agent_bytes: 0,
operator_tx: None,
operator_detached_at: Some(Instant::now()),
replay: VecDeque::new(),
replay_bytes: 0,
});
if session.agent_id != agent_id || session.agent_tx.is_some() {
continue;
}
session.agent_confirmed = true;
let token = new_token();
session.relay_token = Some(token.clone());
credentials.push((reported_session.terminal_id.clone(), token));
}
credentials
}
pub async fn detach_agent(&self, terminal_id: &str) -> Option<String> {
let mut sessions = self.inner.lock().await;
let session = sessions.get_mut(terminal_id)?;
session.agent_tx = None;
Some(session.agent_id.clone())
}
pub async fn issue_attachment_token(&self, terminal_id: &str) -> Result<String, &'static str> {
@@ -189,6 +252,7 @@ impl TerminalRegistry {
return Err("terminal_relay_token_invalid");
}
session.relay_token = None;
session.agent_confirmed = true;
let (tx, rx) = mpsc::channel(TERMINAL_RELAY_QUEUE);
session.agent_tx = Some(tx);
let pending = session.pending_agent.drain(..).collect();
@@ -211,8 +275,9 @@ impl TerminalRegistry {
}
session.attachment_token = None;
session.operator_detached_at = None;
let replay = session.replay.drain(..).collect();
session.replay_bytes = 0;
// Keep the rolling transcript after attachment so a newly mounted
// browser can reconstruct recent terminal state.
let replay = session.replay.iter().cloned().collect();
let (tx, rx) = mpsc::channel(TERMINAL_RELAY_QUEUE);
session.operator_tx = Some(tx);
Ok((rx, replay))
@@ -255,17 +320,17 @@ impl TerminalRegistry {
terminal_id: &str,
frame: TerminalRelayFrame,
) -> Result<(), &'static str> {
let operator_tx = self
.inner
.lock()
.await
.get(terminal_id)
.and_then(|session| session.operator_tx.clone());
let operator_tx = {
let mut sessions = self.inner.lock().await;
let session = active_session(&mut sessions, terminal_id)?;
push_replay(session, frame.clone());
session.operator_tx.clone()
};
if let Some(tx) = operator_tx {
match tx.send(frame).await {
Ok(()) => return Ok(()),
Err(err) => {
Err(_) => {
// The browser task may not have marked itself detached yet.
// Preserve this frame so that race does not kill the PTY.
let mut sessions = self.inner.lock().await;
@@ -274,15 +339,10 @@ impl TerminalRegistry {
session
.operator_detached_at
.get_or_insert_with(Instant::now);
push_replay(session, err.0);
return Ok(());
}
}
}
let mut sessions = self.inner.lock().await;
let session = active_session(&mut sessions, terminal_id)?;
push_replay(session, frame);
Ok(())
}
@@ -303,8 +363,10 @@ impl TerminalRegistry {
}
self.relay_from_agent(terminal_id, TerminalRelayFrame::Text(error_json))
.await?;
self.relay_from_agent(terminal_id, TerminalRelayFrame::Close)
self.remove(terminal_id)
.await
.map(|_| ())
.ok_or("terminal_not_found")
}
pub async fn detach_operator(&self, terminal_id: &str) -> Option<Instant> {
@@ -316,19 +378,6 @@ impl TerminalRegistry {
Some(detached_at)
}
pub async fn remove_if_still_detached(&self, terminal_id: &str, detached_at: Instant) -> bool {
let should_remove = self
.inner
.lock()
.await
.get(terminal_id)
.is_some_and(|session| session.operator_detached_at == Some(detached_at));
if should_remove {
self.remove(terminal_id).await;
}
should_remove
}
pub async fn summary(&self, terminal_id: &str) -> Option<(String, u64, bool, bool)> {
self.inner.lock().await.get(terminal_id).map(|session| {
(
@@ -339,6 +388,22 @@ impl TerminalRegistry {
)
})
}
pub async fn summaries(&self) -> Vec<TerminalSummary> {
let sessions = self.inner.lock().await;
let mut summaries = sessions
.iter()
.map(|(terminal_id, session)| TerminalSummary {
terminal_id: terminal_id.clone(),
agent_id: session.agent_id.clone(),
created_at_unix: session.created_at_unix,
agent_attached: session.agent_tx.is_some(),
operator_attached: session.operator_tx.is_some(),
})
.collect::<Vec<_>>();
summaries.sort_by_key(|session| std::cmp::Reverse(session.created_at_unix));
summaries
}
}
fn active_session<'a>(
@@ -440,6 +505,37 @@ mod tests {
assert!(replay.iter().map(relay_frame_size).sum::<usize>() <= TERMINAL_REPLAY_BYTES);
}
#[tokio::test]
async fn attached_output_remains_available_for_remount() {
let registry = TerminalRegistry::new();
let created = registry.create("router".into()).await.expect("create");
let (mut outbound, _) = registry
.attach_operator(&created.terminal_id, &created.attachment_token)
.await
.expect("attach operator");
let frame = TerminalRelayFrame::Binary(b"recent prompt".to_vec());
registry
.relay_from_agent(&created.terminal_id, frame)
.await
.expect("relay output");
outbound.recv().await.expect("live output");
registry.detach_operator(&created.terminal_id).await;
let token = registry
.issue_attachment_token(&created.terminal_id)
.await
.expect("reattach token");
let (_, replay) = registry
.attach_operator(&created.terminal_id, &token)
.await
.expect("reattach operator");
assert!(matches!(
replay.as_slice(),
[TerminalRelayFrame::Binary(bytes)] if bytes == b"recent prompt"
));
}
#[tokio::test]
async fn operator_input_waits_for_agent_attachment() {
let registry = TerminalRegistry::new();
@@ -478,4 +574,64 @@ mod tests {
assert!(registry.was_closed(&created.terminal_id).await);
assert!(!registry.was_closed("never-existed").await);
}
#[tokio::test]
async fn agent_inventory_adopts_and_reconnects_live_session() {
let registry = TerminalRegistry::new();
let terminal_id = TerminalId::new("survived-cc").expect("terminal id");
let reported = AgentTerminalSession {
terminal_id: terminal_id.clone(),
created_at_unix: 42,
};
let credentials = registry
.reconcile_agent_sessions("router", &[reported])
.await;
assert_eq!(credentials.len(), 1);
let (_, relay_token) = &credentials[0];
registry
.attach_agent(terminal_id.as_str(), "router", relay_token)
.await
.expect("attach adopted agent session");
let summary = registry
.summary(terminal_id.as_str())
.await
.expect("adopted summary");
assert_eq!(summary.0, "router");
assert_eq!(summary.1, 42);
assert!(summary.2);
}
#[tokio::test]
async fn operator_detach_does_not_remove_session() {
let registry = TerminalRegistry::new();
let created = registry.create("router".into()).await.expect("create");
registry
.attach_operator(&created.terminal_id, &created.attachment_token)
.await
.expect("attach operator");
registry.detach_operator(&created.terminal_id).await;
let summary = registry
.summary(&created.terminal_id)
.await
.expect("session remains after detach");
assert!(!summary.3);
registry
.issue_attachment_token(&created.terminal_id)
.await
.expect("detached session can be reattached");
}
#[tokio::test]
async fn reconciliation_does_not_remove_open_request_still_in_flight() {
let registry = TerminalRegistry::new();
let created = registry.create("router".into()).await.expect("create");
registry.reconcile_agent_sessions("router", &[]).await;
assert!(registry.summary(&created.terminal_id).await.is_some());
}
}
+27 -2
View File
@@ -9,7 +9,8 @@ use tokio::sync::mpsc;
use tracing::{debug, info, info_span, warn};
use uuid::Uuid;
use wakey_agent::protocol::{
AgentCapability, ErrorPayload, RequestId, ServerMessage, TerminalControl, TerminalId,
AgentCapability, AgentTerminalSession, ErrorPayload, RequestId, ServerMessage, TerminalControl,
TerminalId,
};
use wakey_core::Device;
@@ -47,6 +48,9 @@ enum IncomingClientMessage {
terminal_id: TerminalId,
error: ErrorPayload,
},
TerminalSessions {
sessions: Vec<AgentTerminalSession>,
},
}
#[derive(Default)]
@@ -150,7 +154,6 @@ async fn handle_agent_socket(state: AppState, socket: WebSocket) {
sessions.remove(&agent_id);
}
drop(sessions);
state.terminals.remove_agent(&agent_id).await;
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
@@ -338,6 +341,28 @@ async fn process_agent_text(
}
warn!(terminal_id = %terminal_id, agent_id, "agent rejected terminal request");
}
IncomingClientMessage::TerminalSessions { sessions } => {
let agent_id = connection
.authed_agent_id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("terminal inventory before auth"))?;
ensure_current_session(state, agent_id, connection_id).await?;
let credentials = state
.terminals
.reconcile_agent_sessions(agent_id, &sessions)
.await;
for (terminal_id, relay_token) in credentials {
let _ = tx.send(SessionEvent::Message(ServerMessage::ResumeTerminal {
terminal_id,
relay_token,
}));
}
info!(
agent_id,
sessions = sessions.len(),
"agent terminal sessions reconciled"
);
}
}
Ok(())