gate4agent-types 0.4.6

Pure data contracts for gate4agent backends and shells
Documentation
use crate::{
    normalize_semantic_prompt, validate_candidate_id, HistoryValidationError, OperationId,
    ProviderSessionIdentity, ProviderSessionKey, TerminalSize, WORKING_DIRECTORY_MAX_BYTES,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;

pub const RESUME_ERROR_MAX_BYTES: usize = 4_096;

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ResumeLaunchRequest {
    pub working_directory: String,
    pub terminal_size: TerminalSize,
    #[serde(default)]
    pub initial_prompt: Option<String>,
}

impl ResumeLaunchRequest {
    pub fn validate(&self) -> Result<(), ResumeValidationError> {
        if self.working_directory.trim().is_empty()
            || self.working_directory.len() > WORKING_DIRECTORY_MAX_BYTES
            || self.working_directory.chars().any(char::is_control)
        {
            return Err(ResumeValidationError::InvalidWorkingDirectory);
        }
        if !self.terminal_size.is_valid() {
            return Err(ResumeValidationError::InvalidTerminalSize);
        }
        if let Some(prompt) = &self.initial_prompt {
            normalize_semantic_prompt(prompt)
                .map_err(|error| ResumeValidationError::InvalidInitialPrompt(error.to_string()))?;
        }
        Ok(())
    }
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum ResumeTarget {
    CurrentProvider,
    ProviderSession { identity: ProviderSessionIdentity },
    HistoryCandidate { candidate_id: String },
}

impl ResumeTarget {
    pub fn validate(&self) -> Result<(), ResumeValidationError> {
        match self {
            Self::CurrentProvider => Ok(()),
            Self::ProviderSession { identity } => identity
                .validate()
                .map_err(|error| ResumeValidationError::InvalidProviderSession(error.to_string())),
            Self::HistoryCandidate { candidate_id } => validate_candidate_id(candidate_id)
                .map_err(ResumeValidationError::InvalidHistoryCandidate),
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum ResumeAuthorityTarget {
    ProviderSession { identity: ProviderSessionIdentity },
    HistoryCandidate { candidate_id: String },
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ResumePhase {
    Authorizing,
    Spawning,
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct PendingResumeOperation {
    pub operation_id: OperationId,
    pub target: ResumeTarget,
    pub request: ResumeLaunchRequest,
    pub phase: ResumePhase,
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ResumeSessionSummary {
    pub key: ProviderSessionKey,
    pub id: String,
}

impl From<&ProviderSessionIdentity> for ResumeSessionSummary {
    fn from(identity: &ProviderSessionIdentity) -> Self {
        Self {
            key: identity.key,
            id: identity.id.clone(),
        }
    }
}

#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct ResumeSnapshot {
    pub pending: Option<PendingResumeOperation>,
    pub last_session: Option<ResumeSessionSummary>,
    pub last_error: Option<String>,
}

#[derive(Clone, Debug, Error, Eq, PartialEq)]
pub enum ResumeValidationError {
    #[error("resume working directory is empty, too large, or contains controls")]
    InvalidWorkingDirectory,
    #[error("resume terminal size is outside the supported bounded range")]
    InvalidTerminalSize,
    #[error("resume initial prompt is invalid: {0}")]
    InvalidInitialPrompt(String),
    #[error("resume history candidate is invalid: {0}")]
    InvalidHistoryCandidate(HistoryValidationError),
    #[error("resume provider session is invalid: {0}")]
    InvalidProviderSession(String),
    #[error("resume error is empty, too large, or contains unsafe controls")]
    InvalidError,
}

pub fn validate_resume_error(message: &str) -> Result<(), ResumeValidationError> {
    if message.trim().is_empty()
        || message.len() > RESUME_ERROR_MAX_BYTES
        || message
            .chars()
            .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
    {
        return Err(ResumeValidationError::InvalidError);
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn resume_requests_and_public_summaries_are_bounded() {
        assert_eq!(
            ResumeLaunchRequest {
                working_directory: "C:/repo".to_owned(),
                terminal_size: TerminalSize {
                    rows: 24,
                    columns: 80,
                },
                initial_prompt: Some("continue\u{0000} safely".to_owned()),
            }
            .validate(),
            Ok(())
        );
        assert!(ResumeLaunchRequest {
            working_directory: "C:/repo".to_owned(),
            terminal_size: TerminalSize {
                rows: 24,
                columns: 80,
            },
            initial_prompt: Some("x".repeat(crate::SEMANTIC_PROMPT_MAX_BYTES + 1)),
        }
        .validate()
        .is_err());
        assert!(ResumeTarget::HistoryCandidate {
            candidate_id: r"C:\sessions\one.jsonl".to_owned(),
        }
        .validate()
        .is_err());
        assert!(ResumeTarget::ProviderSession {
            identity: ProviderSessionIdentity {
                key: ProviderSessionKey::SessionId,
                id: "-unsafe-option".to_owned(),
                transcript_path: None,
            },
        }
        .validate()
        .is_err());
        assert!(validate_resume_error("authorization failed").is_ok());
        assert!(validate_resume_error("bad\0error").is_err());
    }
}