wsb 0.258.6441

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
use crate::prelude::*;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DBSession {
    pub id: String,
    /// The `$PRJ` project the session ran in; none outside `$PRJ`.
    pub project: Option<String>,
    /// The project's worktree it ran in: `main` or the name `prj` gave it.
    pub worktree: Option<String>,
    pub machine: String,
    pub agent: String,
    pub first_message: Option<String>,
    pub display_name: Option<String>,
    pub started_at: Option<String>,
    pub ended_at: Option<String>,
    pub message_count: i64,
    pub user_message_count: i64,
    pub file_path: Option<String>,
    pub file_size: Option<i64>,
    pub file_mtime: Option<i64>,
    pub file_hash: Option<String>,
    pub parent_session_id: Option<String>,
    pub relationship_type: String,
    pub total_output_tokens: i64,
    pub peak_context_tokens: i64,
    pub has_total_output_tokens: bool,
    pub has_peak_context_tokens: bool,
    pub is_automated: bool,
    pub cwd: String,
    pub git_branch: String,
    pub source_session_id: String,
    pub source_version: String,
    pub parser_malformed_lines: i64,
    pub is_truncated: bool,
    pub deleted_at: Option<String>,
    pub created_at: String,
}

#[derive(Debug, Clone)]
pub struct SessionInsert {
    pub id: String,
    pub machine: String,
    pub agent: String,
    pub first_message: Option<String>,
    pub started_at: Option<String>,
    pub ended_at: Option<String>,
    pub message_count: i64,
    pub user_message_count: i64,
    pub file_path: Option<String>,
    pub file_size: Option<i64>,
    pub file_mtime: Option<i64>,
    pub file_hash: Option<String>,
    pub parent_session_id: Option<String>,
    pub relationship_type: String,
    pub total_output_tokens: i64,
    pub peak_context_tokens: i64,
    pub has_total_output_tokens: bool,
    pub has_peak_context_tokens: bool,
    pub is_automated: bool,
    pub cwd: String,
    pub git_branch: String,
    pub source_session_id: String,
    pub source_version: String,
    pub parser_malformed_lines: i64,
    pub is_truncated: bool,
}

impl Default for SessionInsert {
    fn default() -> Self {
        Self {
            id: String::new(),
            machine: "local".to_string(),
            agent: "claude".to_string(),
            first_message: None,
            started_at: None,
            ended_at: None,
            message_count: 0,
            user_message_count: 0,
            file_path: None,
            file_size: None,
            file_mtime: None,
            file_hash: None,
            parent_session_id: None,
            relationship_type: String::new(),
            total_output_tokens: 0,
            peak_context_tokens: 0,
            has_total_output_tokens: false,
            has_peak_context_tokens: false,
            is_automated: false,
            cwd: String::new(),
            git_branch: String::new(),
            source_session_id: String::new(),
            source_version: String::new(),
            parser_malformed_lines: 0,
            is_truncated: false,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionFileInfo {
    pub file_path: Option<String>,
    pub file_size: Option<i64>,
    pub file_mtime: Option<i64>,
    pub file_hash: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
    pub session_id: String,
    pub project: Option<String>,
    pub worktree: Option<String>,
    pub agent: String,
    pub name: String,
    pub ordinal: i64,
    pub session_ended_at: Option<String>,
    pub snippet: String,
    pub rank: f64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pin {
    pub id: i64,
    pub session_id: String,
    pub message_id: i64,
    pub ordinal: i64,
    pub note: Option<String>,
    pub created_at: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub content: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub role: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub session_project: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub session_worktree: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub session_agent: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub session_display_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub session_first_message: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Stats {
    pub session_count: i64,
    pub message_count: i64,
    pub project_count: i64,
    pub machine_count: i64,
    pub earliest_session: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionPlaceCount {
    pub project: Option<String>,
    pub worktree: Option<String>,
    pub session_count: i64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentInfo {
    pub name: String,
    pub session_count: i64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageSummary {
    pub total_input_tokens: i64,
    pub total_output_tokens: i64,
    pub total_cache_creation_tokens: i64,
    pub total_cache_read_tokens: i64,
    pub estimated_cost_usd: f64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DailyUsage {
    pub date: String,
    pub session_count: i64,
    pub message_count: i64,
    pub input_tokens: i64,
    pub output_tokens: i64,
    pub cache_creation_tokens: i64,
    pub cache_read_tokens: i64,
    pub estimated_cost_usd: f64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelUsage {
    pub model: String,
    pub session_count: i64,
    pub input_tokens: i64,
    pub output_tokens: i64,
    pub cache_creation_tokens: i64,
    pub cache_read_tokens: i64,
    pub estimated_cost_usd: f64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectUsage {
    pub project: Option<String>,
    pub session_count: i64,
    pub message_count: i64,
    pub input_tokens: i64,
    pub output_tokens: i64,
    pub cache_creation_tokens: i64,
    pub cache_read_tokens: i64,
    pub estimated_cost_usd: f64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageFilter {
    pub date_from: Option<String>,
    pub date_to: Option<String>,
    pub place: PlaceFilter,
    pub agent: Option<String>,
    pub model: Option<String>,
}

#[derive(Debug, Default, Clone)]
pub struct SessionFilter {
    pub place: PlaceFilter,
    pub machine: Option<Vec<String>>,
    pub agent: Option<Vec<String>>,
    pub date: Option<String>,
    pub date_from: Option<String>,
    pub date_to: Option<String>,
    pub active_since: Option<String>,
    pub min_messages: Option<i64>,
    pub max_messages: Option<i64>,
    pub min_user_messages: Option<i64>,
    pub include_one_shot: bool,
    pub include_children: bool,
}

/// Where a session ran: the `$PRJ` project and worktree its working
/// directory is in, as `prj` names them.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionPlace {
    pub project: Option<String>,
    pub worktree: Option<String>,
}

/// Which sessions a question is about, by where they ran.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlaceFilter {
    /// Only this project's.
    pub project: Option<String>,
    /// Only this worktree's, of `project`.
    pub worktree: Option<String>,
    /// Only sessions that ran in some project.
    pub placed: bool,
}

impl PlaceFilter {
    /// The filter a request asked for, or why it cannot be one: a worktree
    /// is a worktree OF a project, and naming one without it is ambiguous.
    pub fn new(project: Option<String>, worktree: Option<String>, placed: bool) -> Result<Self, String> {
        if worktree.is_some() && project.is_none() {
            return Err("a worktree filter needs the project it belongs to".to_string());
        }
        Ok(Self { project, worktree, placed })
    }

    /// Append this filter's conditions on the sessions table `alias`.
    pub fn push_sql(&self, alias: &str, sql: &mut String, binds: &mut Vec<Box<dyn rusqlite::ToSql>>) {
        if let Some(project) = &self.project {
            sql.push_str(&format!(" AND {alias}.project = ?"));
            binds.push(Box::new(project.clone()));
        }
        if let Some(worktree) = &self.worktree {
            sql.push_str(&format!(" AND {alias}.worktree = ?"));
            binds.push(Box::new(worktree.clone()));
        }
        if self.placed {
            sql.push_str(&format!(" AND {alias}.project IS NOT NULL"));
        }
    }
}