ssctl 0.1.0

Rust CLI for orchestrating Superset terminal-backed agent sessions
Documentation
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,
        }
    }
}