use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use axum::{
extract::{Path as AxumPath, State},
response::IntoResponse,
};
use serde::Serialize;
use tracing::warn;
use trusty_common::credentials::SecretResolveError;
use crate::daemon::rpc::managed::outcome::RouteOutcome;
use crate::daemon::state::DaemonState;
use super::parse_id;
fn tail_lines(text: &str, n: usize) -> String {
let lines: Vec<&str> = text.lines().collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}
const KEY_PRESENCE_TTL: Duration = Duration::from_secs(30);
static KEY_PRESENCE: KeyPresence = KeyPresence::new(KEY_PRESENCE_TTL);
struct KeyPresence {
ttl: Duration,
slot: Mutex<Option<(Instant, bool)>>,
}
impl KeyPresence {
const fn new(ttl: Duration) -> Self {
Self {
ttl,
slot: Mutex::new(None),
}
}
fn cached(&self, now: Instant) -> Option<bool> {
let slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner);
slot.filter(|(at, _)| now.saturating_duration_since(*at) < self.ttl)
.map(|(_, present)| present)
}
async fn present<F>(&self, probe: F) -> bool
where
F: FnOnce() -> bool + Send + 'static,
{
if let Some(present) = self.cached(Instant::now()) {
return present;
}
let dispatch = tracing::dispatcher::get_default(Clone::clone);
let present = tokio::task::spawn_blocking(move || {
tracing::dispatcher::with_default(&dispatch, probe)
})
.await
.unwrap_or(false);
*self.slot.lock().unwrap_or_else(PoisonError::into_inner) = Some((Instant::now(), present));
present
}
}
fn classifier_key_resolves(
resolve: impl FnOnce(&str) -> Result<String, SecretResolveError>,
) -> bool {
resolve(trusty_common::env_vars::ENV_OPENROUTER_API_KEY).is_ok()
}
#[must_use]
pub fn classifier_key_present() -> bool {
classifier_key_resolves(crate::secret_source::resolve_bounded_gated)
}
#[derive(Debug, Serialize)]
pub struct ActivityResponse {
pub raw_pane: String,
pub runtime_active: bool,
pub pane_stale: bool,
pub state: String,
pub summary: String,
pub confidence: f32,
pub cache_hit: bool,
pub input_tokens: u32,
pub output_tokens: u32,
pub latency_ms: u64,
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub classification: Option<String>,
pub pending_decision: Option<String>,
pub proposed_default: Option<String>,
}
pub async fn get_session_activity(
State(state): State<Arc<DaemonState>>,
AxumPath(id_str): AxumPath<String>,
) -> impl IntoResponse {
activity_core(&state, &id_str).await }
pub(crate) async fn activity_core(state: &Arc<DaemonState>, id_str: &str) -> RouteOutcome {
let id = match parse_id(id_str) {
Ok(id) => id,
Err((code, msg)) => return RouteOutcome::text(code.as_u16(), msg),
};
let mgr = state.session_manager().await;
let record = match mgr.get(&id).await {
Ok(r) => r,
Err(_) => return RouteOutcome::text(404, format!("session {id_str} not found")),
};
let pane_text = mgr
.capture_pane(&id, 60)
.await
.unwrap_or_else(|_| String::new());
let runtime_active = mgr.tmux_driver().session_exists(&record.tmux_name);
let (raw_pane, pane_stale) = if !runtime_active && pane_text.is_empty() {
let snapshot = if let Some(p) = record.scrollback_path.as_deref() {
tokio::fs::read_to_string(p)
.await
.ok()
.map(|s| tail_lines(&s, 60))
.filter(|s| !s.is_empty())
} else {
None
};
match snapshot {
Some(snap) => (snap, true),
None => (String::new(), true), }
} else {
(pane_text, false)
};
let monitor = state.activity_monitor();
let result = match monitor.check(id_str, &raw_pane).await {
Ok(r) => r,
Err(e) => {
warn!(session = %id_str, "activity check error (non-key): {e}");
return RouteOutcome::text(500, format!("activity check failed: {e}"));
}
};
let api_key_present = KEY_PRESENCE.present(classifier_key_present).await;
let classification = if api_key_present {
Some(format!("{:?}", result.verdict.state).to_lowercase())
} else {
None
};
RouteOutcome::ok(&ActivityResponse {
raw_pane,
runtime_active,
pane_stale,
state: format!("{:?}", result.verdict.state).to_lowercase(),
summary: result.verdict.summary,
confidence: result.verdict.confidence,
cache_hit: result.cache_hit,
input_tokens: result.cost.input_tokens,
output_tokens: result.cost.output_tokens,
latency_ms: result.cost.latency_ms,
total_input_tokens: result.tally.total_input_tokens,
total_output_tokens: result.tally.total_output_tokens,
classification,
pending_decision: record.pending_decision,
proposed_default: record.proposed_default,
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::prelude::*;
use trusty_common::credential_registry::provider_for_env_var;
use trusty_common::credentials::{KeyStore, MemoryKeyStore};
use super::*;
fn store_only_resolver(
store: Arc<MemoryKeyStore>,
) -> impl FnOnce(&str) -> Result<String, SecretResolveError> + Send + 'static {
move |var: &str| {
provider_for_env_var(var)
.and_then(|p| store.get(p))
.ok_or_else(|| SecretResolveError::Absent {
var: var.to_string(),
})
}
}
struct CountWarnings(Arc<AtomicUsize>);
impl<S: tracing::Subscriber> Layer<S> for CountWarnings {
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
if *event.metadata().level() <= tracing::Level::WARN {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
}
#[tokio::test]
async fn classifier_key_is_found_in_the_store_when_the_env_lacks_it() {
let store = Arc::new(MemoryKeyStore::new());
store
.set("openrouter", "sk-test-not-real")
.expect("memory store accepts a write");
let presence = KeyPresence::new(KEY_PRESENCE_TTL);
let resolve = store_only_resolver(store);
assert!(presence.present(|| classifier_key_resolves(resolve)).await);
}
#[tokio::test]
async fn an_absent_key_logs_nothing_on_repeated_requests() {
let warnings = Arc::new(AtomicUsize::new(0));
let _guard = tracing::subscriber::set_default(
tracing_subscriber::registry().with(CountWarnings(Arc::clone(&warnings))),
);
let store = Arc::new(MemoryKeyStore::new());
let probes = Arc::new(AtomicUsize::new(0));
let presence = KeyPresence::new(KEY_PRESENCE_TTL);
for _ in 0..5 {
let (store, probes) = (Arc::clone(&store), Arc::clone(&probes));
let present = presence
.present(move || {
probes.fetch_add(1, Ordering::SeqCst);
classifier_key_resolves(store_only_resolver(store))
})
.await;
assert!(!present, "an absent key is absent");
}
assert_eq!(probes.load(Ordering::SeqCst), 1, "one probe per TTL");
assert_eq!(warnings.load(Ordering::SeqCst), 0, "no per-request log");
assert_eq!(
presence.cached(Instant::now() + KEY_PRESENCE_TTL),
None,
"the answer expires, so a key added later is seen"
);
}
}