diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProjectConversations.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProjectConversations.test.tsx new file mode 100644 index 00000000000..96edcf268ff --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProjectConversations.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import ProjectConversations, { + CONVERSATION_INITIAL_ROWS, + CONVERSATION_MAX_ROWS, +} from "./ProjectConversations"; +import type { CodexWorkspacesSessionUsage } from "../../../types/bridge"; +import type { LocaleKey } from "../../../i18n/keys"; + +const t = (key: LocaleKey) => key; + +function session(index: number, unknownTokens = 0): CodexWorkspacesSessionUsage { + return { + id: `session-${index}`, + projectId: "project-a", + displayTitle: `Thread ${index}`, + cwd: null, + startedAt: null, + latestActivity: null, + totals: { inputTokens: 10, cachedInputTokens: 0, outputTokens: 0, totalTokens: 10 }, + costEstimate: { knownUsd: 100 - index, unknownTokens }, + topModel: null, + }; +} + +describe("ProjectConversations", () => { + it("shows ranked rows in the given order with their rank", () => { + render(); + + expect(screen.getByText("#1")).toBeTruthy(); + expect(screen.getByText("#2")).toBeTruthy(); + expect(screen.getByText("Thread 1").getAttribute("title")).toBe("Thread 1"); + expect(screen.getByText(/\$99\.00/)).toBeTruthy(); + expect(screen.getByText(/~\$98\.00/)).toBeTruthy(); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("shows the top rows first and expands up to the display limit", () => { + const conversations = Array.from({ length: CONVERSATION_MAX_ROWS + 5 }, (_, i) => session(i + 1)); + render(); + + expect(screen.getAllByText(/^#\d+$/)).toHaveLength(CONVERSATION_INITIAL_ROWS); + fireEvent.click(screen.getByRole("button", { name: `UsageSpendShowAll (${CONVERSATION_MAX_ROWS})` })); + expect(screen.getAllByText(/^#\d+$/)).toHaveLength(CONVERSATION_MAX_ROWS); + expect(screen.queryByText(`Thread ${CONVERSATION_MAX_ROWS + 1}`)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "UsageSpendShowLess" })); + expect(screen.getAllByText(/^#\d+$/)).toHaveLength(CONVERSATION_INITIAL_ROWS); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProjectConversations.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProjectConversations.tsx new file mode 100644 index 00000000000..948d1b0bc4d --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProjectConversations.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import type { LocaleKey } from "../../../i18n/keys"; +import type { CodexWorkspacesSessionUsage } from "../../../types/bridge"; + +/** Conversations shown before the expand control, and the most ever shown. */ +export const CONVERSATION_INITIAL_ROWS = 8; +export const CONVERSATION_MAX_ROWS = 50; + +interface ProjectConversationsProps { + /** Already ranked by the backend: cost descending, unpriced last. */ + conversations: CodexWorkspacesSessionUsage[]; + t: (key: LocaleKey) => string; +} + +export default function ProjectConversations({ conversations, t }: ProjectConversationsProps) { + const [showAll, setShowAll] = useState(false); + const capped = conversations.slice(0, CONVERSATION_MAX_ROWS); + const visible = showAll ? capped : capped.slice(0, CONVERSATION_INITIAL_ROWS); + + return ( +
+ {visible.map((session, index) => ( +
+ + #{index + 1} + + + {session.displayTitle} + + + {session.costEstimate.unknownTokens > 0 ? "~" : ""} + ${session.costEstimate.knownUsd.toFixed(2)} + +
+ ))} + {capped.length > CONVERSATION_INITIAL_ROWS && ( + + )} +
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx index 0f7cc7ee899..ed0fcc90051 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx @@ -16,6 +16,7 @@ import { import type { CostSummaryDisplayStyle, SettingsSnapshot, SpendContract, UsageSpendSummary } from "../../../types/bridge"; import type { LocaleKey } from "../../../i18n/keys"; import type { TabProps } from "../settingsTabs"; +import ProjectConversations from "./ProjectConversations"; export default function UsageSpendTab(_props: TabProps) { const { t } = useLocale(); @@ -471,24 +472,10 @@ function ProjectsPanel({ {isExpanded && ( -
- {contract.conversations - .filter((session) => session.projectId === project.id) - .map((session) => ( -
- - {session.displayTitle} - - - {session.costEstimate.unknownTokens > 0 ? "~" : ""} - ${session.costEstimate.knownUsd.toFixed(2)} - -
- ))} -
+ session.projectId === project.id)} + t={t} + /> )} ); diff --git a/rust/src/cli/cost.rs b/rust/src/cli/cost.rs index 8e9be16dc27..297c12b6095 100755 --- a/rust/src/cli/cost.rs +++ b/rust/src/cli/cost.rs @@ -8,6 +8,7 @@ use super::usage::{OutputFormat, ProviderSelection}; use crate::codex_costs::{ CodexHostCostReport, CodexHostCostsArgs, CodexHostOutcome, run_codex_host_costs, }; +use crate::codex_workspaces::short_session_id; use crate::core::{CostScanOptions, ProviderId}; use crate::cost_scanner::{CostScanner, CostSummary}; use crate::settings::Settings; @@ -436,23 +437,6 @@ fn print_codex_session_output(result: &CostResult, days: u32) { println!(" Not a subscription bill or plan value · local usage × public API prices"); } -fn short_session_id(value: &str) -> String { - let trimmed = value.trim(); - if trimmed.chars().count() <= 12 { - return trimmed.to_string(); - } - let prefix: String = trimmed.chars().take(4).collect(); - let suffix: String = trimmed - .chars() - .rev() - .take(8) - .collect::>() - .into_iter() - .rev() - .collect(); - format!("{prefix}...{suffix}") -} - /// Print JSON output fn build_json_payloads(results: &[CostResult], days: u32) -> Vec { let settings = Settings::load(); @@ -664,12 +648,6 @@ mod tests { assert_eq!(CostGroupBy::from_arg(Some("session")), CostGroupBy::Session); } - #[test] - fn short_session_id_is_privacy_conscious() { - assert_eq!(short_session_id("abc"), "abc"); - assert_eq!(short_session_id("1234567890abcdef"), "1234...90abcdef"); - } - #[test] fn remote_failure_retains_local_report() { let local_summary = CodexCostSummary::from_summaries_at( diff --git a/rust/src/codex_workspaces/indexer.rs b/rust/src/codex_workspaces/indexer.rs index 993b695412b..6af5662f75e 100644 --- a/rust/src/codex_workspaces/indexer.rs +++ b/rust/src/codex_workspaces/indexer.rs @@ -12,9 +12,10 @@ use crate::codex_costs::codex_period_start; use crate::core::{CostUsageDayRange, CostUsagePricing, JsonlScanner, sha256_hex}; use super::sidecar::{SidecarError, WorkspaceUsageSidecar}; +use super::thread_names::apply_session_names_and_ranking; use super::types::{ CodexLocalProjectUsageSnapshot, CostEstimate, DailyPoint, Progress, ProgressPhase, - ProjectUsage, SessionUsage, SourceStatus, UsageTotals, + ProjectUsage, SessionUsage, SourceStatus, UsageTotals, untitled_session_label, }; use super::{CHATS_DISPLAY_NAME, CHATS_PROJECT_ID}; @@ -39,10 +40,9 @@ pub struct CodexLocalDataScope { } impl CodexLocalDataScope { - /// `CODEX_HOME` → `CODEX_SQLITE_HOME` → `~/.codex`. + /// `CODEX_HOME` → `~/.codex`; the SQLite override is only for thread metadata. pub fn resolve() -> Option { let home = non_empty_env("CODEX_HOME") - .or_else(|| non_empty_env("CODEX_SQLITE_HOME")) .map(PathBuf::from) .or_else(|| dirs::home_dir().map(|h| h.join(".codex")))?; Some(Self::from_home(home)) @@ -148,6 +148,11 @@ impl CodexWorkspacesIndex { match sidecar.load_latest_snapshot(scope.scope_signature(), self.history_days) { Ok(Some(mut cached)) => { cached.source_status = source_status; + apply_session_names_and_ranking( + &mut cached, + &scope.codex_home, + non_empty_env("CODEX_SQLITE_HOME").as_deref(), + ); return Ok(cached); } Ok(None) => {} @@ -241,17 +246,12 @@ impl CodexWorkspacesIndex { .collect(); daily.sort_by(|a, b| a.day.cmp(&b.day)); - let mut sessions: Vec = session_buckets + let sessions: Vec = session_buckets .values() .map(SessionBucket::to_session_usage) .collect(); - sessions.sort_by(|a, b| { - b.latest_activity - .cmp(&a.latest_activity) - .then_with(|| a.id.cmp(&b.id)) - }); - let snapshot = CodexLocalProjectUsageSnapshot { + let mut snapshot = CodexLocalProjectUsageSnapshot { updated_at: Utc::now(), history_days: self.history_days, scope_signature: scope.scope_signature().to_string(), @@ -263,6 +263,11 @@ impl CodexWorkspacesIndex { daily, source_status, }; + apply_session_names_and_ranking( + &mut snapshot, + &scope.codex_home, + non_empty_env("CODEX_SQLITE_HOME").as_deref(), + ); progress(Progress::phase(ProgressPhase::Saving)); sidecar.publish_snapshot(&snapshot)?; @@ -359,7 +364,7 @@ impl SessionBucket { .title .clone() .filter(|t| !t.trim().is_empty()) - .unwrap_or_else(|| "Local Codex chat".to_string()), + .unwrap_or_else(|| untitled_session_label(&self.id)), cwd: self.cwd.clone(), started_at: self.started_at, latest_activity: self.latest_activity, @@ -412,11 +417,6 @@ fn build_projects(sessions: &HashMap) -> Vec) -> Vec = day.split('-').collect(); + let folder = home + .join("sessions") + .join(parts[0]) + .join(parts[1]) + .join(parts[2]); + fs::create_dir_all(&folder).unwrap(); + let mut file = File::create(folder.join(format!("{id}.jsonl"))).unwrap(); + writeln!( + file, + r#"{{"timestamp":"{day}T10:00:00.000Z","type":"session_meta","payload":{{"session_id":"{id}","cwd":"{cwd}","originator":"codex_exec","source":"cli"}}}}"#, + cwd = cwd.to_string_lossy().replace('\\', "\\\\"), + ) + .unwrap(); + writeln!( + file, + r#"{{"timestamp":"{day}T10:00:01.000Z","type":"turn_context","payload":{{"model":"gpt-5"}}}}"# + ) + .unwrap(); + writeln!( + file, + r#"{{"timestamp":"{day}T10:00:02.000Z","type":"event_msg","payload":{{"type":"token_count","info":{{"last_token_usage":{{"input_tokens":{input},"cached_input_tokens":0,"output_tokens":{output}}}}}}}}}"# + ) + .unwrap(); +} + +/// Three sessions in one project; `cheap` < `middle` < `dear` by cost. +fn build_home(root: &Path, named: bool) -> CodexLocalProjectUsageSnapshot { + let home = root.join("codex"); + let project = root.join("project"); + fs::create_dir_all(&project).unwrap(); + let short = "short-id"; + let long = "019f79b9-1790-7921-8d6f-258a1e92b191"; + write_session(&home, short, &project, 1_000, 100); + write_session(&home, "middle-session", &project, 20_000, 1_000); + write_session(&home, long, &project, 900_000, 90_000); + + if named { + // Pin the SQLite home so a developer's CODEX_SQLITE_HOME cannot interfere. + fs::write( + home.join("config.toml"), + format!( + "sqlite_home = '{}'\n", + home.canonicalize().unwrap().display() + ), + ) + .unwrap(); + fs::write( + home.join("session_index.jsonl"), + "{\"id\":\"middle-session\",\"thread_name\":\"Fix the icon\"}\n", + ) + .unwrap(); + let conn = Connection::open(home.join("state_5.sqlite")).unwrap(); + conn.execute_batch( + "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT); + INSERT INTO threads (id, title) VALUES ('short-id', 'Database title'); + INSERT INTO threads (id, title) VALUES ('middle-session', 'Ignored, index wins');", + ) + .unwrap(); + } + + CodexWorkspacesIndex::new(30) + .with_codex_home(&home) + .with_sidecar_path(root.join("sidecar.sqlite")) + .load_snapshot(true, |_| {}) + .expect("snapshot") +} + +#[test] +fn sessions_get_thread_names_and_rank_by_cost() { + let tmp = TempDir::new().unwrap(); + let snapshot = build_home(tmp.path(), true); + + let titles: Vec<_> = snapshot + .sessions + .iter() + .map(|s| s.display_title.as_str()) + .collect(); + // Most expensive first; an untitled session shows its short id. + assert_eq!( + titles, + ["Session 019f...1e92b191", "Fix the icon", "Database title"] + ); + let top: Vec<_> = snapshot.projects[0] + .top_sessions + .iter() + .map(|s| s.display_title.as_str()) + .collect(); + assert_eq!(top, titles); +} + +#[test] +fn untitled_sessions_show_the_short_id() { + let tmp = TempDir::new().unwrap(); + let snapshot = build_home(tmp.path(), false); + let titles: Vec<_> = snapshot + .sessions + .iter() + .map(|s| s.display_title.clone()) + .collect(); + assert_eq!( + titles, + [ + format!( + "Session {}", + short_session_id("019f79b9-1790-7921-8d6f-258a1e92b191") + ), + "Session midd...-session".to_string(), + "Session short-id".to_string(), + ] + ); +} + +#[test] +fn naming_and_ranking_do_not_change_totals_or_costs() { + let plain = tempfile::tempdir().unwrap(); + let named = tempfile::tempdir().unwrap(); + let plain = build_home(plain.path(), false); + let named = build_home(named.path(), true); + + assert_eq!(plain.total, named.total); + assert_eq!(plain.daily, named.daily); + assert_eq!(plain.projects.len(), named.projects.len()); + assert_eq!( + plain.projects[0].cost_estimate, + named.projects[0].cost_estimate + ); + let costs = |snapshot: &CodexLocalProjectUsageSnapshot| { + snapshot + .sessions + .iter() + .map(|s| (s.id.clone(), s.cost_estimate, s.totals)) + .collect::>() + }; + assert_eq!(costs(&plain), costs(&named)); +} + +#[test] +fn hide_personal_info_masks_names_with_the_short_id() { + let tmp = TempDir::new().unwrap(); + let mut snapshot = build_home(tmp.path(), true); + snapshot.redact_for_privacy(); + + let titles: Vec<_> = snapshot + .sessions + .iter() + .map(|s| s.display_title.as_str()) + .collect(); + assert_eq!( + titles, + [ + "Session 019f...1e92b191", + "Session midd...-session", + "Session short-id" + ] + ); + assert!(snapshot.sessions.iter().all(|s| s.cwd.is_none())); + assert!(snapshot.projects.iter().all(|p| p.path.is_none())); + assert!( + snapshot.projects[0] + .top_sessions + .iter() + .all(|s| s.display_title.starts_with("Session ")) + ); +} diff --git a/rust/src/codex_workspaces/thread_names.rs b/rust/src/codex_workspaces/thread_names.rs new file mode 100644 index 00000000000..18d5f06cb41 --- /dev/null +++ b/rust/src/codex_workspaces/thread_names.rs @@ -0,0 +1,334 @@ +//! Codex thread names for indexed sessions (upstream 0.68.0 `CodexThreadMetadataReader`). +//! +//! Codex keeps thread names outside the rollout files, so they are overlaid onto +//! usage snapshots. Order: `session_index.jsonl` in the Codex home (latest line +//! wins), then `threads.title` in the newest `state_.sqlite`. Everything is +//! read-only and best effort: any failure leaves the session untitled. + +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader, Read}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use rusqlite::{Connection, OpenFlags, OptionalExtension}; +use serde::Deserialize; + +use super::types::{CodexLocalProjectUsageSnapshot, SessionUsage, untitled_session_label}; + +/// Lines longer than this in `session_index.jsonl` are skipped. +const SESSION_INDEX_MAX_LINE_BYTES: u64 = 64 * 1024; +/// Only the head of `config.toml` is read when looking for `sqlite_home`. +const CONFIG_PREFIX_BYTES: u64 = 256 * 1024; +const BUSY_TIMEOUT: Duration = Duration::from_millis(100); +const DEFAULT_STATE_DATABASE: &str = "state_5.sqlite"; +const PROJECT_TOP_SESSION_LIMIT: usize = 5; + +/// A session whose thread name is wanted: its id and original working directory. +pub(super) struct SessionRef<'a> { + pub id: &'a str, + pub cwd: Option<&'a str>, +} + +#[derive(Deserialize)] +struct SessionIndexEntry { + id: String, + thread_name: String, +} + +/// Thread names keyed by session id for the given sessions. +/// +/// `env_sqlite_home` is the raw `CODEX_SQLITE_HOME` value, passed in so callers +/// (and tests) control the environment. +pub(super) fn thread_titles( + codex_home: &Path, + env_sqlite_home: Option<&str>, + sessions: &[SessionRef<'_>], +) -> HashMap { + if sessions.is_empty() { + return HashMap::new(); + } + let ids: HashSet<&str> = sessions.iter().map(|s| s.id).collect(); + let mut titles = indexed_thread_names(&codex_home.join("session_index.jsonl"), &ids); + if titles.len() == ids.len() { + return titles; + } + + let configured_home = configured_sqlite_home(codex_home); + let mut newest_by_dir: HashMap = HashMap::new(); + let mut ids_by_database: HashMap> = HashMap::new(); + for session in sessions { + if titles.contains_key(session.id) { + continue; + } + let dir = sqlite_home( + codex_home, + configured_home.as_deref(), + env_sqlite_home, + session.cwd, + ); + let database = newest_by_dir + .entry(dir.clone()) + .or_insert_with(|| newest_state_database(&dir)) + .clone(); + ids_by_database + .entry(database) + .or_default() + .push(session.id); + } + for (database, session_ids) in ids_by_database { + for (id, title) in database_titles(&database, &session_ids) { + // The session index is the explicit name; the database is the fallback. + titles.entry(id).or_insert(title); + } + } + titles +} + +/// Apply metadata names and ranking to fresh and cached snapshots. +/// Names never touch totals, cost, or project grouping. +pub(super) fn apply_session_names_and_ranking( + snapshot: &mut CodexLocalProjectUsageSnapshot, + codex_home: &Path, + env_sqlite_home: Option<&str>, +) { + let has_sessions = !snapshot.sessions.is_empty(); + let titles = { + let mut seen = HashSet::new(); + let mut refs = Vec::new(); + for session in &snapshot.sessions { + if seen.insert(session.id.as_str()) { + refs.push(SessionRef { + id: &session.id, + cwd: session.cwd.as_deref(), + }); + } + } + if !has_sessions { + for session in snapshot + .projects + .iter() + .flat_map(|project| project.top_sessions.iter()) + { + if seen.insert(session.id.as_str()) { + refs.push(SessionRef { + id: &session.id, + cwd: session.cwd.as_deref(), + }); + } + } + } + thread_titles(codex_home, env_sqlite_home, &refs) + }; + + for session in &mut snapshot.sessions { + apply_thread_title(session, &titles); + } + snapshot.sessions.sort_by(SessionUsage::rank_cmp); + + if has_sessions { + let mut top_sessions_by_project: HashMap> = HashMap::new(); + for session in &snapshot.sessions { + let top_sessions = top_sessions_by_project + .entry(session.project_id.clone()) + .or_default(); + if top_sessions.len() < PROJECT_TOP_SESSION_LIMIT { + top_sessions.push(session.clone()); + } + } + for project in &mut snapshot.projects { + project.top_sessions = top_sessions_by_project + .remove(&project.id) + .unwrap_or_default(); + } + } else { + for project in &mut snapshot.projects { + for session in &mut project.top_sessions { + apply_thread_title(session, &titles); + } + project.top_sessions.sort_by(SessionUsage::rank_cmp); + project.top_sessions.truncate(PROJECT_TOP_SESSION_LIMIT); + } + } +} + +fn apply_thread_title(session: &mut SessionUsage, titles: &HashMap) { + session.display_title = titles + .get(&session.id) + .cloned() + .unwrap_or_else(|| untitled_session_label(&session.id)); +} + +/// `session_index.jsonl`: `{"id": ..., "thread_name": ...}` per line, latest wins. +fn indexed_thread_names(path: &Path, ids: &HashSet<&str>) -> HashMap { + let mut names = HashMap::new(); + let Ok(file) = File::open(path) else { + return names; + }; + let mut reader = BufReader::new(file); + let mut line = Vec::new(); + loop { + line.clear(); + let Ok(read) = (&mut reader) + .take(SESSION_INDEX_MAX_LINE_BYTES + 1) + .read_until(b'\n', &mut line) + else { + break; + }; + if read == 0 { + break; + } + if line.last() != Some(&b'\n') && read as u64 > SESSION_INDEX_MAX_LINE_BYTES { + // Over-long line: drop the remainder without buffering it. + if !skip_to_line_end(&mut reader) { + break; + } + continue; + } + let Ok(entry) = serde_json::from_slice::(&line) else { + continue; + }; + let name = entry.thread_name.trim(); + if !name.is_empty() && ids.contains(entry.id.as_str()) { + names.insert(entry.id, name.to_string()); + } + } + names +} + +/// Consume input up to and including the next newline. Returns `false` at EOF or on error. +fn skip_to_line_end(reader: &mut R) -> bool { + loop { + let Ok(buffer) = reader.fill_buf() else { + return false; + }; + if buffer.is_empty() { + return false; + } + match buffer.iter().position(|byte| *byte == b'\n') { + Some(index) => { + reader.consume(index + 1); + return true; + } + None => { + let len = buffer.len(); + reader.consume(len); + } + } + } +} + +/// Directory holding `state_.sqlite` for one session. +fn sqlite_home( + codex_home: &Path, + configured_home: Option<&Path>, + env_sqlite_home: Option<&str>, + cwd: Option<&str>, +) -> PathBuf { + if let Some(configured) = configured_home { + return configured.to_path_buf(); + } + if let Some(raw) = env_sqlite_home.map(str::trim).filter(|v| !v.is_empty()) { + let path = Path::new(raw); + if path.is_absolute() { + return path.to_path_buf(); + } + if let Some(cwd) = cwd.map(str::trim).filter(|c| !c.is_empty()) { + return Path::new(cwd).join(path); + } + } + codex_home.to_path_buf() +} + +/// Absolute top-level `sqlite_home` from `/config.toml`. +/// +/// Only the user config is read: project, managed and profile layers are not +/// persisted with the rollout and an untrusted checkout must not redirect the +/// lookup. Only the first 256 KiB is read. +fn configured_sqlite_home(codex_home: &Path) -> Option { + let file = File::open(codex_home.join("config.toml")).ok()?; + let mut bytes = Vec::new(); + file.take(CONFIG_PREFIX_BYTES) + .read_to_end(&mut bytes) + .ok()?; + let contents = String::from_utf8_lossy(&bytes); + let raw = top_level_sqlite_home(&contents)?; + let path = PathBuf::from(raw); + path.is_absolute().then_some(path) +} + +fn top_level_sqlite_home(contents: &str) -> Option { + let table = contents.parse::().ok().or_else(|| { + // A truncated or malformed table section must not hide a valid + // top-level key, so retry with everything before the first table header. + let mut offset = 0usize; + let mut end = contents.len(); + for line in contents.split_inclusive('\n') { + if line.trim_start().starts_with('[') { + end = offset; + break; + } + offset += line.len(); + } + contents[..end].parse::().ok() + })?; + match table.get("sqlite_home")? { + toml::Value::String(value) => Some(value.clone()), + _ => None, + } +} + +/// Highest `state_.sqlite` in `dir`, else the default `state_5.sqlite` path. +fn newest_state_database(dir: &Path) -> PathBuf { + let newest = fs::read_dir(dir).ok().and_then(|entries| { + entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name().into_string().ok()?; + let version = name + .strip_prefix("state_")? + .strip_suffix(".sqlite")? + .parse::() + .ok()?; + if !entry.metadata().ok()?.is_file() { + return None; + } + Some((version, entry.path())) + }) + .max_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1))) + .map(|(_, path)| path) + }); + newest.unwrap_or_else(|| dir.join(DEFAULT_STATE_DATABASE)) +} + +/// `SELECT title FROM threads WHERE id = ?1 LIMIT 1` for each id, read-only. +fn database_titles(database: &Path, ids: &[&str]) -> Vec<(String, String)> { + if !database.is_file() { + return Vec::new(); + } + let Ok(conn) = Connection::open_with_flags( + database, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) else { + return Vec::new(); + }; + if conn.busy_timeout(BUSY_TIMEOUT).is_err() { + return Vec::new(); + } + let Ok(mut statement) = conn.prepare("SELECT title FROM threads WHERE id = ?1 LIMIT 1") else { + return Vec::new(); + }; + ids.iter() + .filter_map(|id| { + let title = statement + .query_row([id], |row| row.get::<_, Option>(0)) + .optional() + .ok()??; + let title = title?.trim().to_string(); + (!title.is_empty()).then(|| ((*id).to_string(), title)) + }) + .collect() +} + +#[cfg(test)] +mod tests; diff --git a/rust/src/codex_workspaces/thread_names/tests.rs b/rust/src/codex_workspaces/thread_names/tests.rs new file mode 100644 index 00000000000..287cf639bc5 --- /dev/null +++ b/rust/src/codex_workspaces/thread_names/tests.rs @@ -0,0 +1,239 @@ +use super::*; +use tempfile::TempDir; + +fn session<'a>(id: &'a str, cwd: Option<&'a str>) -> SessionRef<'a> { + SessionRef { id, cwd } +} + +fn write_state(dir: &Path, file: &str, rows: &[(&str, Option<&str>)]) { + fs::create_dir_all(dir).unwrap(); + let conn = Connection::open(dir.join(file)).unwrap(); + conn.execute_batch("CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT)") + .unwrap(); + for (id, title) in rows { + conn.execute( + "INSERT INTO threads (id, title) VALUES (?1, ?2)", + (id, title), + ) + .unwrap(); + } +} + +fn write_index(home: &Path, contents: &str) { + fs::create_dir_all(home).unwrap(); + fs::write(home.join("session_index.jsonl"), contents).unwrap(); +} + +fn titles(home: &Path, env: Option<&str>, sessions: &[SessionRef<'_>]) -> HashMap { + thread_titles(home, env, sessions) +} + +fn toml_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "\\\\") +} + +#[test] +fn session_index_latest_line_wins_and_blank_names_are_ignored() { + let tmp = TempDir::new().unwrap(); + write_index( + tmp.path(), + concat!( + "{\"id\":\"a\",\"thread_name\":\"First\"}\n", + "{\"id\":\"a\",\"thread_name\":\" Renamed \"}\n", + "{\"id\":\"b\",\"thread_name\":\" \"}\n", + "{\"id\":\"c\",\"thread_name\":\"Not requested\"}\n", + "not json\n", + "{\"id\":\"d\",\"thread_name\":\"No trailing newline\"}", + ), + ); + let found = titles( + tmp.path(), + None, + &[session("a", None), session("b", None), session("d", None)], + ); + assert_eq!(found.get("a").map(String::as_str), Some("Renamed")); + assert_eq!( + found.get("d").map(String::as_str), + Some("No trailing newline") + ); + assert!(!found.contains_key("b")); + assert!(!found.contains_key("c")); +} + +#[test] +fn session_index_skips_lines_over_64_kib() { + let tmp = TempDir::new().unwrap(); + let huge = "x".repeat(70 * 1024); + write_index( + tmp.path(), + &format!( + "{{\"id\":\"a\",\"thread_name\":\"{huge}\"}}\n{{\"id\":\"b\",\"thread_name\":\"After\"}}\n" + ), + ); + let found = titles(tmp.path(), None, &[session("a", None), session("b", None)]); + assert!(!found.contains_key("a")); + assert_eq!(found.get("b").map(String::as_str), Some("After")); +} + +#[test] +fn database_title_is_the_fallback_and_the_index_wins() { + let tmp = TempDir::new().unwrap(); + write_state( + tmp.path(), + "state_5.sqlite", + &[ + ("a", Some("Database name")), + ("b", Some(" Trimmed ")), + ("c", None), + ("d", Some(" ")), + ], + ); + write_index( + tmp.path(), + "{\"id\":\"a\",\"thread_name\":\"Index name\"}\n", + ); + let found = titles( + tmp.path(), + None, + &[ + session("a", None), + session("b", None), + session("c", None), + session("d", None), + session("missing", None), + ], + ); + assert_eq!(found.get("a").map(String::as_str), Some("Index name")); + assert_eq!(found.get("b").map(String::as_str), Some("Trimmed")); + assert_eq!(found.len(), 2); +} + +#[test] +fn newest_state_database_version_is_used() { + let tmp = TempDir::new().unwrap(); + write_state(tmp.path(), "state_5.sqlite", &[("a", Some("old"))]); + write_state(tmp.path(), "state_10.sqlite", &[("a", Some("newest"))]); + write_state(tmp.path(), "state_7.sqlite", &[("a", Some("middle"))]); + let found = titles(tmp.path(), None, &[session("a", None)]); + assert_eq!(found.get("a").map(String::as_str), Some("newest")); +} + +#[test] +fn missing_or_corrupt_database_yields_no_titles() { + let tmp = TempDir::new().unwrap(); + assert!(titles(tmp.path(), None, &[session("a", None)]).is_empty()); + fs::write(tmp.path().join("state_5.sqlite"), b"not a database").unwrap(); + assert!(titles(tmp.path(), None, &[session("a", None)]).is_empty()); +} + +#[test] +fn config_sqlite_home_must_be_absolute_and_top_level() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let other = tmp.path().join("other"); + fs::create_dir_all(&home).unwrap(); + write_state(&home, "state_5.sqlite", &[("a", Some("from home"))]); + write_state(&other, "state_5.sqlite", &[("a", Some("from config"))]); + + let config = home.join("config.toml"); + fs::write( + &config, + format!("sqlite_home = \"{}\"\n", toml_path(&other)), + ) + .unwrap(); + let found = titles(&home, None, &[session("a", None)]); + assert_eq!(found.get("a").map(String::as_str), Some("from config")); + + fs::write(&config, "sqlite_home = \"relative/dir\"\n").unwrap(); + let found = titles(&home, None, &[session("a", None)]); + assert_eq!(found.get("a").map(String::as_str), Some("from home")); + + fs::write( + &config, + format!( + "model = \"x\"\n[projects.p]\nsqlite_home = \"{}\"\n", + toml_path(&other) + ), + ) + .unwrap(); + let found = titles(&home, None, &[session("a", None)]); + assert_eq!(found.get("a").map(String::as_str), Some("from home")); +} + +#[test] +fn config_top_level_key_survives_a_malformed_table_section() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let other = tmp.path().join("other"); + fs::create_dir_all(&home).unwrap(); + write_state(&other, "state_5.sqlite", &[("a", Some("from config"))]); + fs::write( + home.join("config.toml"), + format!( + "sqlite_home = \"{}\"\r\n[broken\r\nkey = \r\n", + toml_path(&other) + ), + ) + .unwrap(); + let found = titles(&home, None, &[session("a", None)]); + assert_eq!(found.get("a").map(String::as_str), Some("from config")); +} + +#[test] +fn config_beats_environment_which_beats_codex_home() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let from_config = tmp.path().join("cfg"); + let from_env = tmp.path().join("env"); + fs::create_dir_all(&home).unwrap(); + write_state(&home, "state_5.sqlite", &[("a", Some("home"))]); + write_state(&from_env, "state_5.sqlite", &[("a", Some("env"))]); + write_state(&from_config, "state_5.sqlite", &[("a", Some("config"))]); + let env = from_env.to_string_lossy().into_owned(); + + let found = titles(&home, Some(&env), &[session("a", None)]); + assert_eq!(found.get("a").map(String::as_str), Some("env")); + + fs::write( + home.join("config.toml"), + format!("sqlite_home = '{}'\n", from_config.display()), + ) + .unwrap(); + let found = titles(&home, Some(&env), &[session("a", None)]); + assert_eq!(found.get("a").map(String::as_str), Some("config")); +} + +#[test] +fn relative_environment_home_resolves_against_the_session_cwd() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let cwd_one = tmp.path().join("repo-one"); + let cwd_two = tmp.path().join("repo-two"); + fs::create_dir_all(&home).unwrap(); + write_state(&home, "state_5.sqlite", &[("c", Some("home title"))]); + write_state(&cwd_one.join("db"), "state_5.sqlite", &[("a", Some("one"))]); + write_state(&cwd_two.join("db"), "state_5.sqlite", &[("b", Some("two"))]); + let one = cwd_one.to_string_lossy().into_owned(); + let two = cwd_two.to_string_lossy().into_owned(); + + let found = titles( + &home, + Some("db"), + &[ + session("a", Some(&one)), + session("b", Some(&two)), + // No working directory: a relative path cannot be resolved, use the home. + session("c", None), + ], + ); + assert_eq!(found.get("a").map(String::as_str), Some("one")); + assert_eq!(found.get("b").map(String::as_str), Some("two")); + assert_eq!(found.get("c").map(String::as_str), Some("home title")); +} + +#[test] +fn no_sessions_reads_nothing() { + let tmp = TempDir::new().unwrap(); + write_index(tmp.path(), "{\"id\":\"a\",\"thread_name\":\"x\"}\n"); + assert!(titles(tmp.path(), None, &[]).is_empty()); +} diff --git a/rust/src/codex_workspaces/types.rs b/rust/src/codex_workspaces/types.rs index e96af15da3e..2849069bc3a 100644 --- a/rust/src/codex_workspaces/types.rs +++ b/rust/src/codex_workspaces/types.rs @@ -1,5 +1,7 @@ //! Snapshot DTOs for Codex local Workspaces indexing. +use std::cmp::Ordering; + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -132,6 +134,48 @@ pub struct SessionUsage { pub top_model: Option, } +impl SessionUsage { + /// Cost used for ranking: `None` when nothing could be priced, so unpriced + /// sessions sort after priced ones (a priced zero stays priced). + pub fn ranking_cost_usd(&self) -> Option { + let cost = self.cost_estimate; + (cost.unknown_tokens == 0 || cost.known_usd > 0.0).then_some(cost.known_usd) + } + + /// Ranking order for the sessions panel: cost descending with unpriced + /// last, then tokens descending, latest activity descending, id ascending. + pub fn rank_cmp(&self, other: &Self) -> Ordering { + match (self.ranking_cost_usd(), other.ranking_cost_usd()) { + (Some(left), Some(right)) => right.total_cmp(&left), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + } + .then_with(|| other.totals.total_tokens.cmp(&self.totals.total_tokens)) + .then_with(|| other.latest_activity.cmp(&self.latest_activity)) + .then_with(|| self.id.cmp(&other.id)) + } +} + +/// Short display form of a session id: ids over 12 characters keep the first 4 +/// and last 8 (`019f...1e92b191`). +pub fn short_session_id(value: &str) -> String { + let trimmed = value.trim(); + let count = trimmed.chars().count(); + if count <= 12 { + return trimmed.to_string(); + } + let prefix: String = trimmed.chars().take(4).collect(); + let suffix: String = trimmed.chars().skip(count - 8).collect(); + format!("{prefix}...{suffix}") +} + +/// Label for a session without a thread name, and the masked label under +/// Hide personal information. The session id is not personal data. +pub fn untitled_session_label(session_id: &str) -> String { + format!("Session {}", short_session_id(session_id)) +} + /// Per-project usage aggregate. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -170,7 +214,7 @@ impl CodexLocalProjectUsageSnapshot { /// Does not rewrite the sidecar. pub fn redact_for_privacy(&mut self) { for session in &mut self.sessions { - session.display_title = "Local Codex chat".to_string(); + session.display_title = untitled_session_label(&session.id); session.cwd = None; } for project in &mut self.projects { @@ -181,9 +225,12 @@ impl CodexLocalProjectUsageSnapshot { } project.path = None; for session in &mut project.top_sessions { - session.display_title = "Local Codex chat".to_string(); + session.display_title = untitled_session_label(&session.id); session.cwd = None; } } } } + +#[cfg(test)] +mod tests; diff --git a/rust/src/codex_workspaces/types/tests.rs b/rust/src/codex_workspaces/types/tests.rs new file mode 100644 index 00000000000..25df8bbdd21 --- /dev/null +++ b/rust/src/codex_workspaces/types/tests.rs @@ -0,0 +1,82 @@ +use super::*; + +fn session(id: &str, cost: Option, tokens: u64, minutes: i64) -> SessionUsage { + SessionUsage { + id: id.to_string(), + project_id: "p".to_string(), + display_title: id.to_string(), + cwd: None, + started_at: None, + latest_activity: Some(DateTime::::UNIX_EPOCH + chrono::Duration::minutes(minutes)), + totals: UsageTotals::from_parts(tokens, 0, 0), + cost_estimate: match cost { + Some(known_usd) => CostEstimate { + known_usd, + unknown_tokens: 0, + }, + None => CostEstimate { + known_usd: 0.0, + unknown_tokens: tokens, + }, + }, + top_model: None, + } +} + +fn ranked(mut sessions: Vec) -> Vec { + sessions.sort_by(SessionUsage::rank_cmp); + sessions.into_iter().map(|s| s.id).collect() +} + +#[test] +fn ranking_orders_by_cost_then_tokens_then_activity_then_id_with_unpriced_last() { + let sessions = vec![ + session("b", Some(1.0), 10, 5), + session("a", Some(1.0), 10, 5), + session("older", Some(1.0), 10, 1), + session("more-tokens", Some(1.0), 20, 5), + session("free", Some(0.0), 10, 5), + session("unpriced", None, 999, 5), + session("expensive", Some(5.0), 1, 0), + ]; + let expected = [ + "expensive", + "more-tokens", + "a", + "b", + "older", + "free", + "unpriced", + ]; + for offset in 0..sessions.len() { + let mut rotated = sessions.clone(); + rotated.rotate_left(offset); + assert_eq!(ranked(rotated.clone()), expected); + rotated.reverse(); + assert_eq!(ranked(rotated), expected); + } +} + +#[test] +fn partially_priced_sessions_rank_by_their_known_cost() { + let mut partial = session("partial", Some(2.0), 10, 0); + partial.cost_estimate.unknown_tokens = 4; + assert_eq!(partial.ranking_cost_usd(), Some(2.0)); + let unpriced = session("unpriced", None, 10, 0); + assert_eq!(unpriced.ranking_cost_usd(), None); +} + +#[test] +fn short_session_id_keeps_first_four_and_last_eight_of_long_ids() { + assert_eq!(short_session_id("abc"), "abc"); + assert_eq!(short_session_id(" exactly-12ch "), "exactly-12ch"); + assert_eq!( + short_session_id("019f79b9-1790-7921-8d6f-258a1e92b191"), + "019f...1e92b191" + ); + assert_eq!(short_session_id("fixture-session"), "fixt...-session"); + assert_eq!( + untitled_session_label("019f79b9-1790-7921-8d6f-258a1e92b191"), + "Session 019f...1e92b191" + ); +}