use std::collections::BTreeSet;
use anyhow::{Result, bail};
use serde::Serialize;
use serde_json::Value;
use crate::terminal_host::TerminalSessionInfo;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SpawnResolution {
pub session: TerminalSessionInfo,
pub strategy: SpawnResolutionStrategy,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum SpawnResolutionStrategy {
VerifiedCliSessionId,
SingleNewWorkspaceSession,
}
pub fn resolve_spawned_session(
before: &[TerminalSessionInfo],
after: &[TerminalSessionInfo],
workspace_id: &str,
cli_stdout: &str,
) -> Result<SpawnResolution> {
let direct_matches = verified_cli_session_matches(
after,
workspace_id,
extract_session_id_candidates(cli_stdout),
);
if direct_matches.len() == 1 {
return Ok(SpawnResolution {
session: direct_matches[0].clone(),
strategy: SpawnResolutionStrategy::VerifiedCliSessionId,
});
}
if direct_matches.len() > 1 {
bail!("spawn output matched multiple terminal-host sessions; registry was not updated");
}
let before_ids = before
.iter()
.map(|session| session.session_id.as_str())
.collect::<BTreeSet<_>>();
let new_workspace_sessions = after
.iter()
.filter(|session| session.is_alive)
.filter(|session| session.workspace_id.as_deref() == Some(workspace_id))
.filter(|session| !before_ids.contains(session.session_id.as_str()))
.cloned()
.collect::<Vec<_>>();
if new_workspace_sessions.len() == 1 {
return Ok(SpawnResolution {
session: new_workspace_sessions[0].clone(),
strategy: SpawnResolutionStrategy::SingleNewWorkspaceSession,
});
}
if new_workspace_sessions.len() > 1 {
bail!("spawn created multiple candidate terminal-host sessions; registry was not updated");
}
bail!("could not uniquely correlate created Superset session; registry was not updated")
}
pub fn extract_session_id_candidates(stdout: &str) -> BTreeSet<String> {
let Ok(value) = serde_json::from_str::<Value>(stdout) else {
return BTreeSet::new();
};
let mut candidates = BTreeSet::new();
collect_candidate_values(&value, None, &mut candidates);
candidates
}
fn verified_cli_session_matches(
sessions: &[TerminalSessionInfo],
workspace_id: &str,
candidates: BTreeSet<String>,
) -> Vec<TerminalSessionInfo> {
if candidates.is_empty() {
return Vec::new();
}
sessions
.iter()
.filter(|session| session.is_alive)
.filter(|session| session.workspace_id.as_deref() == Some(workspace_id))
.filter(|session| candidates.contains(&session.session_id))
.cloned()
.collect()
}
fn collect_candidate_values(value: &Value, key: Option<&str>, candidates: &mut BTreeSet<String>) {
match value {
Value::Object(map) => {
for (child_key, child_value) in map {
collect_candidate_values(child_value, Some(child_key), candidates);
}
}
Value::Array(values) => {
for child in values {
collect_candidate_values(child, key, candidates);
}
}
Value::String(text) => {
if key.is_some_and(is_session_id_key) && looks_like_id(text) {
candidates.insert(text.clone());
}
}
_ => {}
}
}
fn is_session_id_key(key: &str) -> bool {
let lower = key.to_ascii_lowercase();
lower.contains("session") && lower.ends_with("id")
}
fn looks_like_id(value: &str) -> bool {
value.len() >= 8
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
}
#[cfg(test)]
mod tests {
use chrono::Utc;
use super::{SpawnResolutionStrategy, extract_session_id_candidates, resolve_spawned_session};
use crate::terminal_host::TerminalSessionInfo;
#[test]
fn extracts_only_session_id_like_fields() {
let candidates = extract_session_id_candidates(
r#"{"sessionId":"s-12345678","workspaceId":"w-1","id":"ambiguous"}"#,
);
assert!(candidates.contains("s-12345678"));
assert!(!candidates.contains("ambiguous"));
}
#[test]
fn resolves_verified_cli_session_id() {
let session = session("s-12345678", "w-1");
let resolution = resolve_spawned_session(
&[],
std::slice::from_ref(&session),
"w-1",
r#"{"terminalSessionId":"s-12345678"}"#,
)
.unwrap();
assert_eq!(resolution.session.session_id, "s-12345678");
assert_eq!(
resolution.strategy,
SpawnResolutionStrategy::VerifiedCliSessionId
);
}
#[test]
fn resolves_single_new_workspace_session() {
let before = session("old", "w-1");
let after = session("new", "w-1");
let resolution = resolve_spawned_session(
std::slice::from_ref(&before),
&[before.clone(), after],
"w-1",
"{}",
)
.unwrap();
assert_eq!(resolution.session.session_id, "new");
assert_eq!(
resolution.strategy,
SpawnResolutionStrategy::SingleNewWorkspaceSession
);
}
#[test]
fn does_not_resolve_existing_recent_workspace_session() {
let existing = session("existing", "w-1");
let result = resolve_spawned_session(
std::slice::from_ref(&existing),
std::slice::from_ref(&existing),
"w-1",
"{}",
);
assert!(result.is_err());
}
fn session(session_id: &str, workspace_id: &str) -> TerminalSessionInfo {
TerminalSessionInfo {
session_id: session_id.to_owned(),
workspace_id: Some(workspace_id.to_owned()),
pane_id: None,
is_alive: true,
attached_clients: 0,
pid: None,
created_at: Some(Utc::now().to_rfc3339()),
last_attached_at: None,
shell: None,
}
}
}