1297 lines
43 KiB
Rust
1297 lines
43 KiB
Rust
//! Inspect and repair Codex session state.
|
|
//!
|
|
//! Codex stores session visibility across SQLite (`state_5.sqlite`),
|
|
//! `session_index.jsonl`, and rollout JSONL files. This module keeps those
|
|
//! pieces consistent, implements a reversible cdxs trash, and can copy missing
|
|
//! threads across managed homes.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
use chrono::{DateTime, Utc};
|
|
use rusqlite::{params, Connection, Row};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
|
|
use crate::config_store::Store;
|
|
use crate::{atomic, paths};
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct SessionSummary {
|
|
/// Name of the managed home that owns this session.
|
|
pub home_name: String,
|
|
pub home_path: String,
|
|
pub id: String,
|
|
pub title: Option<String>,
|
|
pub cwd: Option<String>,
|
|
pub updated_at_ms: Option<i64>,
|
|
pub tokens_used: i64,
|
|
pub rollout_path: String,
|
|
pub archived: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct SessionStats {
|
|
pub session: SessionSummary,
|
|
pub rollout_bytes: Option<u64>,
|
|
pub rollout_lines: Option<usize>,
|
|
pub sqlite_tokens_used: i64,
|
|
pub rollout_total_tokens: Option<i64>,
|
|
pub rollout_input_tokens: Option<i64>,
|
|
pub rollout_output_tokens: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct VisibilityIssue {
|
|
pub home_name: String,
|
|
pub home_path: String,
|
|
pub session_id: String,
|
|
pub issue: String,
|
|
pub detail: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct RepairAction {
|
|
pub home_name: String,
|
|
pub session_id: String,
|
|
pub action: String,
|
|
pub detail: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct SyncThreadAction {
|
|
pub session_id: String,
|
|
pub source_home: String,
|
|
pub target_home: String,
|
|
pub action: String,
|
|
pub detail: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct TrashManifest {
|
|
/// Manifest schema version for future migrations.
|
|
version: u32,
|
|
deleted_at: String,
|
|
home_name: String,
|
|
home_path: String,
|
|
session_id: String,
|
|
original_rollout_path: String,
|
|
rollout_backup_file: Option<String>,
|
|
session_index_entries: Vec<String>,
|
|
thread: ThreadRowData,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
struct TrashEntry {
|
|
home_name: String,
|
|
home_path: String,
|
|
session_id: String,
|
|
title: Option<String>,
|
|
cwd: Option<String>,
|
|
deleted_at: String,
|
|
trash_dir: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct ThreadRowData {
|
|
// Mirrors the Codex `threads` table. Keeping this shape explicit makes
|
|
// repair/restore code independent from rusqlite row lifetimes.
|
|
id: String,
|
|
rollout_path: String,
|
|
created_at: Option<i64>,
|
|
updated_at: Option<i64>,
|
|
source: Option<String>,
|
|
model_provider: Option<String>,
|
|
cwd: Option<String>,
|
|
title: Option<String>,
|
|
sandbox_policy: Option<String>,
|
|
approval_mode: Option<String>,
|
|
tokens_used: Option<i64>,
|
|
has_user_event: Option<i64>,
|
|
archived: Option<i64>,
|
|
archived_at: Option<i64>,
|
|
git_sha: Option<String>,
|
|
git_branch: Option<String>,
|
|
git_origin_url: Option<String>,
|
|
cli_version: Option<String>,
|
|
first_user_message: Option<String>,
|
|
agent_nickname: Option<String>,
|
|
agent_role: Option<String>,
|
|
memory_mode: Option<String>,
|
|
model: Option<String>,
|
|
reasoning_effort: Option<String>,
|
|
agent_path: Option<String>,
|
|
created_at_ms: Option<i64>,
|
|
updated_at_ms: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct HomeTarget {
|
|
name: String,
|
|
path: PathBuf,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct RolloutMeta {
|
|
session_id: String,
|
|
relative_path: String,
|
|
cwd: Option<String>,
|
|
model_provider: Option<String>,
|
|
source: Option<String>,
|
|
cli_version: Option<String>,
|
|
created_at_ms: Option<i64>,
|
|
updated_at_ms: Option<i64>,
|
|
}
|
|
|
|
pub fn list_sessions(all_homes: bool, json: bool) -> Result<()> {
|
|
let mut sessions = Vec::new();
|
|
for home in homes(all_homes)? {
|
|
sessions.extend(read_sessions_for_home(&home)?);
|
|
}
|
|
sessions.sort_by(|a, b| b.updated_at_ms.cmp(&a.updated_at_ms));
|
|
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(&sessions)?);
|
|
return Ok(());
|
|
}
|
|
if sessions.is_empty() {
|
|
println!("没有找到 Codex 会话。");
|
|
return Ok(());
|
|
}
|
|
println!(
|
|
"{:<22} {:<12} {:<10} {:<20} {}",
|
|
"ID", "Home", "Tokens", "Updated", "Title"
|
|
);
|
|
for item in sessions {
|
|
println!(
|
|
"{:<22} {:<12} {:<10} {:<20} {}",
|
|
shorten(&item.id, 22),
|
|
shorten(&item.home_name, 12),
|
|
item.tokens_used,
|
|
format_time(item.updated_at_ms),
|
|
shorten(item.title.as_deref().unwrap_or("-"), 80)
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn session_stats(session_id: &str, all_homes: bool, json: bool) -> Result<()> {
|
|
let (home, row) =
|
|
find_thread(session_id, all_homes)?.ok_or_else(|| anyhow!("会话不存在: {session_id}"))?;
|
|
let summary = summary_from_thread(&home, &row);
|
|
let rollout = resolve_rollout_path(&home.path, &row.rollout_path);
|
|
let (rollout_bytes, rollout_lines, token_usage) = rollout_stats(&rollout)?;
|
|
let stats = SessionStats {
|
|
sqlite_tokens_used: row.tokens_used.unwrap_or_default(),
|
|
session: summary,
|
|
rollout_bytes,
|
|
rollout_lines,
|
|
rollout_total_tokens: token_usage.as_ref().and_then(|v| v.total_tokens),
|
|
rollout_input_tokens: token_usage.as_ref().and_then(|v| v.input_tokens),
|
|
rollout_output_tokens: token_usage.as_ref().and_then(|v| v.output_tokens),
|
|
};
|
|
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(&stats)?);
|
|
return Ok(());
|
|
}
|
|
println!("id: {}", stats.session.id);
|
|
println!(
|
|
"home: {} ({})",
|
|
stats.session.home_name, stats.session.home_path
|
|
);
|
|
println!("title: {}", stats.session.title.as_deref().unwrap_or("-"));
|
|
println!("cwd: {}", stats.session.cwd.as_deref().unwrap_or("-"));
|
|
println!("updated: {}", format_time(stats.session.updated_at_ms));
|
|
println!("sqlite_tokens_used: {}", stats.sqlite_tokens_used);
|
|
println!(
|
|
"rollout_tokens: total={}, input={}, output={}",
|
|
opt_i64(stats.rollout_total_tokens),
|
|
opt_i64(stats.rollout_input_tokens),
|
|
opt_i64(stats.rollout_output_tokens)
|
|
);
|
|
println!(
|
|
"rollout_file: {} (bytes={}, lines={})",
|
|
stats.session.rollout_path,
|
|
opt_u64(stats.rollout_bytes),
|
|
stats
|
|
.rollout_lines
|
|
.map(|v| v.to_string())
|
|
.unwrap_or_else(|| "-".to_string())
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn trash_sessions(session_ids: Vec<String>, all_homes: bool) -> Result<()> {
|
|
if session_ids.is_empty() {
|
|
return Err(anyhow!("至少需要提供一个 session id"));
|
|
}
|
|
for session_id in session_ids {
|
|
let (home, row) = find_thread(&session_id, all_homes)?
|
|
.ok_or_else(|| anyhow!("会话不存在: {session_id}"))?;
|
|
trash_one(&home, row)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn list_trash(all_homes: bool, json: bool) -> Result<()> {
|
|
let mut entries = Vec::new();
|
|
for home in homes(all_homes)? {
|
|
entries.extend(read_trash_entries(&home)?);
|
|
}
|
|
entries.sort_by(|a, b| b.deleted_at.cmp(&a.deleted_at));
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(&entries)?);
|
|
return Ok(());
|
|
}
|
|
if entries.is_empty() {
|
|
println!("垃圾箱为空。");
|
|
return Ok(());
|
|
}
|
|
println!(
|
|
"{:<22} {:<12} {:<24} {}",
|
|
"ID", "Home", "Deleted At", "Title"
|
|
);
|
|
for item in entries {
|
|
println!(
|
|
"{:<22} {:<12} {:<24} {}",
|
|
shorten(&item.session_id, 22),
|
|
shorten(&item.home_name, 12),
|
|
item.deleted_at,
|
|
shorten(item.title.as_deref().unwrap_or("-"), 80)
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn restore_sessions(session_ids: Vec<String>, all_homes: bool) -> Result<()> {
|
|
if session_ids.is_empty() {
|
|
return Err(anyhow!("至少需要提供一个 session id"));
|
|
}
|
|
let manifests = trash_manifests(all_homes)?;
|
|
for session_id in session_ids {
|
|
let (manifest_path, manifest) = manifests
|
|
.iter()
|
|
.find(|(_, item)| item.session_id == session_id)
|
|
.cloned()
|
|
.ok_or_else(|| anyhow!("垃圾箱中没有找到会话: {session_id}"))?;
|
|
restore_one(&manifest_path, &manifest)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn visibility_check(all_homes: bool, json: bool) -> Result<()> {
|
|
let issues = collect_visibility_issues(all_homes)?;
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(&issues)?);
|
|
return Ok(());
|
|
}
|
|
if issues.is_empty() {
|
|
println!("没有发现会话可见性问题。");
|
|
return Ok(());
|
|
}
|
|
println!(
|
|
"{:<12} {:<22} {:<18} {}",
|
|
"Home", "Session", "Issue", "Detail"
|
|
);
|
|
for issue in issues {
|
|
println!(
|
|
"{:<12} {:<22} {:<18} {}",
|
|
shorten(&issue.home_name, 12),
|
|
shorten(&issue.session_id, 22),
|
|
issue.issue,
|
|
issue.detail
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn visibility_repair(all_homes: bool, json: bool) -> Result<()> {
|
|
let mut actions = Vec::new();
|
|
for home in homes(all_homes)? {
|
|
actions.extend(repair_home_visibility(&home)?);
|
|
}
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(&actions)?);
|
|
return Ok(());
|
|
}
|
|
if actions.is_empty() {
|
|
println!("没有需要修复的会话可见性问题。");
|
|
return Ok(());
|
|
}
|
|
for action in actions {
|
|
println!(
|
|
"{} {}: {}",
|
|
action.home_name, action.session_id, action.detail
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn sync_threads(all_homes: bool, dry_run: bool, json: bool) -> Result<()> {
|
|
let selected_homes = homes(all_homes)?;
|
|
if selected_homes.len() < 2 {
|
|
return Err(anyhow!(
|
|
"sync-threads 至少需要两个 home,请先用 `cdxs home create` 添加实例"
|
|
));
|
|
}
|
|
|
|
// Choose the first seen copy of each thread as the source of truth, then
|
|
// fill in homes that do not have that thread yet.
|
|
let mut rows_by_id: HashMap<String, (HomeTarget, ThreadRowData)> = HashMap::new();
|
|
let mut indexes: HashMap<String, HashSet<String>> = HashMap::new();
|
|
for home in &selected_homes {
|
|
let rows = read_thread_rows_for_home(home)?;
|
|
indexes.insert(home.name.clone(), read_session_index_ids(&home.path)?);
|
|
for row in rows {
|
|
rows_by_id
|
|
.entry(row.id.clone())
|
|
.or_insert((home.clone(), row));
|
|
}
|
|
}
|
|
|
|
let mut actions = Vec::new();
|
|
for target in &selected_homes {
|
|
let target_rows = read_thread_rows_for_home(target)?;
|
|
let target_ids: HashSet<String> = target_rows.into_iter().map(|row| row.id).collect();
|
|
for (session_id, (source, source_row)) in &rows_by_id {
|
|
if target_ids.contains(session_id) {
|
|
continue;
|
|
}
|
|
let source_rollout = resolve_rollout_path(&source.path, &source_row.rollout_path);
|
|
if !source_rollout.exists() {
|
|
actions.push(SyncThreadAction {
|
|
session_id: session_id.clone(),
|
|
source_home: source.name.clone(),
|
|
target_home: target.name.clone(),
|
|
action: "skip".to_string(),
|
|
detail: "source rollout missing".to_string(),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
let mut target_row = source_row.clone();
|
|
let target_relative = portable_rollout_path(source_row, source, target);
|
|
let target_rollout = resolve_rollout_path(&target.path, &target_relative);
|
|
target_row.rollout_path = target_relative.clone();
|
|
actions.push(SyncThreadAction {
|
|
session_id: session_id.clone(),
|
|
source_home: source.name.clone(),
|
|
target_home: target.name.clone(),
|
|
action: if dry_run { "would_sync" } else { "synced" }.to_string(),
|
|
detail: target_relative.clone(),
|
|
});
|
|
if dry_run {
|
|
continue;
|
|
}
|
|
|
|
backup_state_files(&target.path)?;
|
|
if let Some(parent) = target_rollout.parent() {
|
|
fs::create_dir_all(parent)
|
|
.with_context(|| format!("创建 rollout 目录失败: {}", parent.display()))?;
|
|
}
|
|
fs::copy(&source_rollout, &target_rollout).with_context(|| {
|
|
format!(
|
|
"复制 rollout 失败: source={}, target={}",
|
|
source_rollout.display(),
|
|
target_rollout.display()
|
|
)
|
|
})?;
|
|
let conn = open_state_db(&target.path)?;
|
|
insert_thread(&conn, &target_row)?;
|
|
if !indexes
|
|
.get(&target.name)
|
|
.map(|ids| ids.contains(session_id))
|
|
.unwrap_or(false)
|
|
{
|
|
append_session_index_entry(&target.path, &index_entry_from_thread(&target_row)?)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(&actions)?);
|
|
return Ok(());
|
|
}
|
|
if actions.is_empty() {
|
|
println!("所有 home 的会话线程已经一致。");
|
|
return Ok(());
|
|
}
|
|
for action in actions {
|
|
println!(
|
|
"{} -> {} {}: {}",
|
|
action.source_home, action.target_home, action.session_id, action.action
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn read_sessions_for_home(home: &HomeTarget) -> Result<Vec<SessionSummary>> {
|
|
let db = state_db_path(&home.path);
|
|
if !db.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let conn = Connection::open(&db)
|
|
.with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?;
|
|
let rows = read_thread_rows(&conn)?;
|
|
Ok(rows
|
|
.iter()
|
|
.map(|row| summary_from_thread(home, row))
|
|
.collect())
|
|
}
|
|
|
|
fn read_thread_rows_for_home(home: &HomeTarget) -> Result<Vec<ThreadRowData>> {
|
|
let db = state_db_path(&home.path);
|
|
if !db.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let conn = open_state_db(&home.path)?;
|
|
read_thread_rows(&conn)
|
|
}
|
|
|
|
fn read_thread_rows(conn: &Connection) -> Result<Vec<ThreadRowData>> {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT id, rollout_path, created_at, updated_at, source, model_provider, cwd, title,
|
|
sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at,
|
|
git_sha, git_branch, git_origin_url, cli_version, first_user_message,
|
|
agent_nickname, agent_role, memory_mode, model, reasoning_effort, agent_path,
|
|
created_at_ms, updated_at_ms
|
|
FROM threads",
|
|
)?;
|
|
let rows = stmt
|
|
.query_map([], thread_from_row)?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
Ok(rows)
|
|
}
|
|
|
|
fn find_thread(session_id: &str, all_homes: bool) -> Result<Option<(HomeTarget, ThreadRowData)>> {
|
|
for home in homes(all_homes)? {
|
|
let db = state_db_path(&home.path);
|
|
if !db.exists() {
|
|
continue;
|
|
}
|
|
let conn = Connection::open(&db)
|
|
.with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?;
|
|
let mut stmt = conn.prepare(
|
|
"SELECT id, rollout_path, created_at, updated_at, source, model_provider, cwd, title,
|
|
sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at,
|
|
git_sha, git_branch, git_origin_url, cli_version, first_user_message,
|
|
agent_nickname, agent_role, memory_mode, model, reasoning_effort, agent_path,
|
|
created_at_ms, updated_at_ms
|
|
FROM threads
|
|
WHERE id = ?1",
|
|
)?;
|
|
let mut rows = stmt.query(params![session_id])?;
|
|
if let Some(row) = rows.next()? {
|
|
return Ok(Some((home, thread_from_row(row)?)));
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
fn collect_visibility_issues(all_homes: bool) -> Result<Vec<VisibilityIssue>> {
|
|
let mut issues = Vec::new();
|
|
for home in homes(all_homes)? {
|
|
let threads = read_thread_rows_for_home(&home)?;
|
|
let thread_ids: HashSet<String> = threads.iter().map(|row| row.id.clone()).collect();
|
|
let index_ids = read_session_index_ids(&home.path)?;
|
|
let rollouts = scan_rollouts(&home.path)?;
|
|
let rollout_ids: HashSet<String> = rollouts.keys().cloned().collect();
|
|
|
|
// A visible Codex thread needs a SQLite row, a rollout file, and a
|
|
// session_index.jsonl entry. Report whichever side is missing.
|
|
for row in &threads {
|
|
if !resolve_rollout_path(&home.path, &row.rollout_path).exists() {
|
|
issues.push(issue(
|
|
&home,
|
|
&row.id,
|
|
"missing_rollout",
|
|
format!("rollout not found: {}", row.rollout_path),
|
|
));
|
|
}
|
|
if !index_ids.contains(&row.id) {
|
|
issues.push(issue(
|
|
&home,
|
|
&row.id,
|
|
"missing_index",
|
|
"session_index.jsonl missing entry".to_string(),
|
|
));
|
|
}
|
|
}
|
|
for id in index_ids.difference(&thread_ids) {
|
|
issues.push(issue(
|
|
&home,
|
|
id,
|
|
"orphan_index",
|
|
"session_index.jsonl entry has no SQLite thread".to_string(),
|
|
));
|
|
}
|
|
for id in rollout_ids.difference(&thread_ids) {
|
|
issues.push(issue(
|
|
&home,
|
|
id,
|
|
"orphan_rollout",
|
|
"rollout file has no SQLite thread".to_string(),
|
|
));
|
|
}
|
|
}
|
|
Ok(issues)
|
|
}
|
|
|
|
fn repair_home_visibility(home: &HomeTarget) -> Result<Vec<RepairAction>> {
|
|
let mut actions = Vec::new();
|
|
let db = state_db_path(&home.path);
|
|
if !db.exists() {
|
|
return Ok(actions);
|
|
}
|
|
let conn = open_state_db(&home.path)?;
|
|
let threads = read_thread_rows(&conn)?;
|
|
let mut index_ids = read_session_index_ids(&home.path)?;
|
|
let rollouts = scan_rollouts(&home.path)?;
|
|
let thread_ids: HashSet<String> = threads.iter().map(|row| row.id.clone()).collect();
|
|
|
|
// Existing SQLite rows are preferred. If their rollout path or index entry
|
|
// is missing, repair those pieces in place.
|
|
for row in &threads {
|
|
let current_rollout = resolve_rollout_path(&home.path, &row.rollout_path);
|
|
if !current_rollout.exists() {
|
|
if let Some(found) = rollouts.get(&row.id) {
|
|
backup_state_files(&home.path)?;
|
|
conn.execute(
|
|
"UPDATE threads SET rollout_path = ?1 WHERE id = ?2",
|
|
params![found.relative_path, row.id],
|
|
)?;
|
|
actions.push(repair_action(
|
|
home,
|
|
&row.id,
|
|
"repair_rollout_path",
|
|
format!("updated rollout_path to {}", found.relative_path),
|
|
));
|
|
}
|
|
}
|
|
if !index_ids.contains(&row.id) {
|
|
backup_state_files(&home.path)?;
|
|
append_session_index_entry(&home.path, &index_entry_from_thread(row)?)?;
|
|
index_ids.insert(row.id.clone());
|
|
actions.push(repair_action(
|
|
home,
|
|
&row.id,
|
|
"append_index",
|
|
"added session_index.jsonl entry".to_string(),
|
|
));
|
|
}
|
|
}
|
|
|
|
// Rollout files without SQLite rows can still be made visible by creating a
|
|
// minimal thread row from the rollout metadata.
|
|
for (session_id, rollout) in &rollouts {
|
|
if thread_ids.contains(session_id) {
|
|
continue;
|
|
}
|
|
backup_state_files(&home.path)?;
|
|
let row = thread_from_rollout(rollout);
|
|
insert_thread(&conn, &row)?;
|
|
if !index_ids.contains(session_id) {
|
|
append_session_index_entry(&home.path, &index_entry_from_thread(&row)?)?;
|
|
index_ids.insert(session_id.clone());
|
|
}
|
|
actions.push(repair_action(
|
|
home,
|
|
session_id,
|
|
"insert_thread",
|
|
"created minimal SQLite thread from rollout metadata".to_string(),
|
|
));
|
|
}
|
|
Ok(actions)
|
|
}
|
|
|
|
fn trash_one(home: &HomeTarget, row: ThreadRowData) -> Result<()> {
|
|
let stamp = Utc::now().format("%Y%m%d-%H%M%S%.3f").to_string();
|
|
let trash_dir = home.path.join("cdxs-trash").join(format!(
|
|
"{}-{}-{}",
|
|
stamp,
|
|
safe_name(&row.id),
|
|
safe_name(&home.name)
|
|
));
|
|
fs::create_dir_all(&trash_dir)
|
|
.with_context(|| format!("创建垃圾箱目录失败: {}", trash_dir.display()))?;
|
|
|
|
// Trash is reversible: save the rollout copy, removed index lines and the
|
|
// original SQLite row in a manifest before deleting visibility state.
|
|
let rollout = resolve_rollout_path(&home.path, &row.rollout_path);
|
|
let rollout_backup_file = if rollout.exists() {
|
|
let file_name = rollout
|
|
.file_name()
|
|
.and_then(|value| value.to_str())
|
|
.unwrap_or("rollout.jsonl")
|
|
.to_string();
|
|
fs::copy(&rollout, trash_dir.join(&file_name)).with_context(|| {
|
|
format!(
|
|
"备份 rollout 失败: source={}, trash={}",
|
|
rollout.display(),
|
|
trash_dir.display()
|
|
)
|
|
})?;
|
|
Some(file_name)
|
|
} else {
|
|
None
|
|
};
|
|
let index_entries = remove_session_index_entries(&home.path, &row.id)?;
|
|
let manifest = TrashManifest {
|
|
version: 1,
|
|
deleted_at: Utc::now().to_rfc3339(),
|
|
home_name: home.name.clone(),
|
|
home_path: home.path.to_string_lossy().to_string(),
|
|
session_id: row.id.clone(),
|
|
original_rollout_path: row.rollout_path.clone(),
|
|
rollout_backup_file,
|
|
session_index_entries: index_entries,
|
|
thread: row.clone(),
|
|
};
|
|
let manifest_content = serde_json::to_string_pretty(&manifest)?;
|
|
atomic::write_atomic(&trash_dir.join("manifest.json"), &manifest_content)?;
|
|
|
|
let db = state_db_path(&home.path);
|
|
let conn = Connection::open(&db)
|
|
.with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?;
|
|
conn.execute("DELETE FROM threads WHERE id = ?1", params![row.id])?;
|
|
if rollout.exists() {
|
|
fs::remove_file(&rollout)
|
|
.with_context(|| format!("删除 rollout 文件失败: {}", rollout.display()))?;
|
|
}
|
|
println!(
|
|
"已移入垃圾箱: {} ({})",
|
|
manifest.session_id,
|
|
trash_dir.display()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn restore_one(manifest_path: &Path, manifest: &TrashManifest) -> Result<()> {
|
|
let home_path = PathBuf::from(&manifest.home_path);
|
|
let db = state_db_path(&home_path);
|
|
let conn = Connection::open(&db)
|
|
.with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?;
|
|
// Restore SQLite first, then rollout and index entries. This makes a failed
|
|
// restore easy to retry from the manifest.
|
|
insert_thread(&conn, &manifest.thread)?;
|
|
|
|
if let Some(file_name) = manifest.rollout_backup_file.as_deref() {
|
|
let backup = manifest_path
|
|
.parent()
|
|
.ok_or_else(|| anyhow!("manifest 路径无父目录: {}", manifest_path.display()))?
|
|
.join(file_name);
|
|
let target = resolve_rollout_path(&home_path, &manifest.original_rollout_path);
|
|
if let Some(parent) = target.parent() {
|
|
fs::create_dir_all(parent)
|
|
.with_context(|| format!("创建 rollout 目录失败: {}", parent.display()))?;
|
|
}
|
|
fs::copy(&backup, &target).with_context(|| {
|
|
format!(
|
|
"恢复 rollout 失败: source={}, target={}",
|
|
backup.display(),
|
|
target.display()
|
|
)
|
|
})?;
|
|
}
|
|
restore_session_index_entries(&home_path, &manifest.session_index_entries)?;
|
|
|
|
let trash_dir = manifest_path
|
|
.parent()
|
|
.ok_or_else(|| anyhow!("manifest 路径无父目录: {}", manifest_path.display()))?;
|
|
fs::remove_dir_all(trash_dir)
|
|
.with_context(|| format!("清理垃圾箱条目失败: {}", trash_dir.display()))?;
|
|
println!("已恢复会话: {}", manifest.session_id);
|
|
Ok(())
|
|
}
|
|
|
|
fn remove_session_index_entries(home: &Path, session_id: &str) -> Result<Vec<String>> {
|
|
let path = home.join("session_index.jsonl");
|
|
if !path.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
atomic::backup_if_exists(&path, home, "session_index.jsonl")?;
|
|
let content = fs::read_to_string(&path)
|
|
.with_context(|| format!("读取 session_index 失败: {}", path.display()))?;
|
|
let mut kept = Vec::new();
|
|
let mut removed = Vec::new();
|
|
for line in content.lines() {
|
|
if line_session_id(line).as_deref() == Some(session_id) {
|
|
removed.push(line.to_string());
|
|
} else {
|
|
kept.push(line.to_string());
|
|
}
|
|
}
|
|
let mut output = kept.join("\n");
|
|
if !output.is_empty() {
|
|
output.push('\n');
|
|
}
|
|
atomic::write_atomic(&path, &output)?;
|
|
Ok(removed)
|
|
}
|
|
|
|
fn restore_session_index_entries(home: &Path, entries: &[String]) -> Result<()> {
|
|
if entries.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let path = home.join("session_index.jsonl");
|
|
let mut existing = if path.exists() {
|
|
fs::read_to_string(&path)
|
|
.with_context(|| format!("读取 session_index 失败: {}", path.display()))?
|
|
} else {
|
|
String::new()
|
|
};
|
|
let existing_ids: HashSet<String> = existing.lines().filter_map(line_session_id).collect();
|
|
if !existing.is_empty() && !existing.ends_with('\n') {
|
|
existing.push('\n');
|
|
}
|
|
for entry in entries {
|
|
let should_append = line_session_id(entry)
|
|
.map(|id| !existing_ids.contains(&id))
|
|
.unwrap_or(true);
|
|
if should_append {
|
|
existing.push_str(entry);
|
|
existing.push('\n');
|
|
}
|
|
}
|
|
atomic::backup_if_exists(&path, home, "session_index.jsonl")?;
|
|
atomic::write_atomic(&path, &existing)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn read_session_index_ids(home: &Path) -> Result<HashSet<String>> {
|
|
let path = home.join("session_index.jsonl");
|
|
if !path.exists() {
|
|
return Ok(HashSet::new());
|
|
}
|
|
let content = fs::read_to_string(&path)
|
|
.with_context(|| format!("读取 session_index 失败: {}", path.display()))?;
|
|
Ok(content.lines().filter_map(line_session_id).collect())
|
|
}
|
|
|
|
fn append_session_index_entry(home: &Path, entry: &str) -> Result<()> {
|
|
let path = home.join("session_index.jsonl");
|
|
let mut content = if path.exists() {
|
|
fs::read_to_string(&path)
|
|
.with_context(|| format!("读取 session_index 失败: {}", path.display()))?
|
|
} else {
|
|
String::new()
|
|
};
|
|
if !content.is_empty() && !content.ends_with('\n') {
|
|
content.push('\n');
|
|
}
|
|
content.push_str(entry);
|
|
content.push('\n');
|
|
atomic::write_atomic(&path, &content)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn index_entry_from_thread(row: &ThreadRowData) -> Result<String> {
|
|
// Codex only needs a compact JSONL index entry for the session picker.
|
|
let title = row
|
|
.title
|
|
.as_deref()
|
|
.or(row.first_user_message.as_deref())
|
|
.unwrap_or(&row.id);
|
|
let updated_at = row
|
|
.updated_at_ms
|
|
.or(row.updated_at.map(|value| value * 1000))
|
|
.and_then(DateTime::<Utc>::from_timestamp_millis)
|
|
.unwrap_or_else(Utc::now);
|
|
let value = serde_json::json!({
|
|
"id": row.id,
|
|
"thread_name": title,
|
|
"updated_at": updated_at.to_rfc3339(),
|
|
});
|
|
Ok(serde_json::to_string(&value)?)
|
|
}
|
|
|
|
fn scan_rollouts(home: &Path) -> Result<HashMap<String, RolloutMeta>> {
|
|
let mut out = HashMap::new();
|
|
for dirname in ["sessions", "archived_sessions"] {
|
|
let root = home.join(dirname);
|
|
if root.exists() {
|
|
scan_rollout_dir(home, &root, &mut out)?;
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn scan_rollout_dir(home: &Path, dir: &Path, out: &mut HashMap<String, RolloutMeta>) -> Result<()> {
|
|
for entry in fs::read_dir(dir).with_context(|| format!("读取目录失败: {}", dir.display()))?
|
|
{
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
scan_rollout_dir(home, &path, out)?;
|
|
continue;
|
|
}
|
|
if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
|
|
continue;
|
|
}
|
|
if let Some(meta) = read_rollout_meta(home, &path)? {
|
|
out.entry(meta.session_id.clone()).or_insert(meta);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn read_rollout_meta(home: &Path, path: &Path) -> Result<Option<RolloutMeta>> {
|
|
let content = fs::read_to_string(path)
|
|
.with_context(|| format!("读取 rollout 失败: {}", path.display()))?;
|
|
// session_meta is normally at the top of the rollout. Limit scanning so a
|
|
// malformed large transcript cannot make repair unexpectedly expensive.
|
|
for line in content.lines().take(25) {
|
|
let Ok(value) = serde_json::from_str::<Value>(line) else {
|
|
continue;
|
|
};
|
|
if value.get("type").and_then(Value::as_str) != Some("session_meta") {
|
|
continue;
|
|
}
|
|
let payload = value.get("payload").unwrap_or(&Value::Null);
|
|
let Some(session_id) = payload.get("id").and_then(Value::as_str) else {
|
|
continue;
|
|
};
|
|
let metadata = fs::metadata(path)
|
|
.with_context(|| format!("读取 rollout 元数据失败: {}", path.display()))?;
|
|
let updated_at_ms = metadata
|
|
.modified()
|
|
.ok()
|
|
.and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok())
|
|
.map(|value| value.as_millis() as i64);
|
|
return Ok(Some(RolloutMeta {
|
|
session_id: session_id.to_string(),
|
|
relative_path: relative_path(home, path),
|
|
cwd: payload
|
|
.get("cwd")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string),
|
|
model_provider: payload
|
|
.get("model_provider")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string),
|
|
source: payload
|
|
.get("source")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string),
|
|
cli_version: payload
|
|
.get("cli_version")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string),
|
|
created_at_ms: payload
|
|
.get("timestamp")
|
|
.and_then(Value::as_str)
|
|
.and_then(parse_rfc3339_ms),
|
|
updated_at_ms,
|
|
}));
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
fn thread_from_rollout(meta: &RolloutMeta) -> ThreadRowData {
|
|
let now_ms = Utc::now().timestamp_millis();
|
|
let created_at_ms = meta.created_at_ms.or(meta.updated_at_ms).unwrap_or(now_ms);
|
|
let updated_at_ms = meta.updated_at_ms.unwrap_or(created_at_ms);
|
|
ThreadRowData {
|
|
id: meta.session_id.clone(),
|
|
rollout_path: meta.relative_path.clone(),
|
|
created_at: Some(created_at_ms / 1000),
|
|
updated_at: Some(updated_at_ms / 1000),
|
|
source: meta.source.clone(),
|
|
model_provider: meta
|
|
.model_provider
|
|
.clone()
|
|
.or_else(|| Some("openai".to_string())),
|
|
cwd: meta.cwd.clone(),
|
|
title: Some(meta.session_id.clone()),
|
|
sandbox_policy: None,
|
|
approval_mode: None,
|
|
tokens_used: Some(0),
|
|
has_user_event: Some(1),
|
|
archived: Some(0),
|
|
archived_at: None,
|
|
git_sha: None,
|
|
git_branch: None,
|
|
git_origin_url: None,
|
|
cli_version: meta.cli_version.clone().or_else(|| Some(String::new())),
|
|
first_user_message: Some(String::new()),
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
memory_mode: Some("enabled".to_string()),
|
|
model: None,
|
|
reasoning_effort: None,
|
|
agent_path: None,
|
|
created_at_ms: Some(created_at_ms),
|
|
updated_at_ms: Some(updated_at_ms),
|
|
}
|
|
}
|
|
|
|
fn backup_state_files(home: &Path) -> Result<()> {
|
|
// These are the Codex files session operations may modify.
|
|
let db = state_db_path(home);
|
|
atomic::backup_if_exists(&db, home, "state_5.sqlite")?;
|
|
atomic::backup_if_exists(
|
|
&home.join("session_index.jsonl"),
|
|
home,
|
|
"session_index.jsonl",
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn open_state_db(home: &Path) -> Result<Connection> {
|
|
let db = state_db_path(home);
|
|
Connection::open(&db).with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))
|
|
}
|
|
|
|
fn portable_rollout_path(row: &ThreadRowData, source: &HomeTarget, target: &HomeTarget) -> String {
|
|
let source_rollout = resolve_rollout_path(&source.path, &row.rollout_path);
|
|
let relative = relative_path(&source.path, &source_rollout);
|
|
let target_rollout = resolve_rollout_path(&target.path, &relative);
|
|
if target_rollout.exists() {
|
|
// Avoid overwriting an existing rollout in the target home.
|
|
let file_name = source_rollout
|
|
.file_name()
|
|
.and_then(|value| value.to_str())
|
|
.unwrap_or("rollout.jsonl");
|
|
format!(
|
|
"sessions/cdxs-sync/{}/{}",
|
|
safe_name(&source.name),
|
|
file_name
|
|
)
|
|
} else {
|
|
relative
|
|
}
|
|
}
|
|
|
|
fn relative_path(base: &Path, path: &Path) -> String {
|
|
path.strip_prefix(base)
|
|
.unwrap_or(path)
|
|
.to_string_lossy()
|
|
.replace('\\', "/")
|
|
}
|
|
|
|
fn parse_rfc3339_ms(value: &str) -> Option<i64> {
|
|
DateTime::parse_from_rfc3339(value)
|
|
.ok()
|
|
.map(|value| value.timestamp_millis())
|
|
}
|
|
|
|
fn issue(home: &HomeTarget, session_id: &str, issue: &str, detail: String) -> VisibilityIssue {
|
|
VisibilityIssue {
|
|
home_name: home.name.clone(),
|
|
home_path: home.path.to_string_lossy().to_string(),
|
|
session_id: session_id.to_string(),
|
|
issue: issue.to_string(),
|
|
detail,
|
|
}
|
|
}
|
|
|
|
fn repair_action(
|
|
home: &HomeTarget,
|
|
session_id: &str,
|
|
action: &str,
|
|
detail: String,
|
|
) -> RepairAction {
|
|
RepairAction {
|
|
home_name: home.name.clone(),
|
|
session_id: session_id.to_string(),
|
|
action: action.to_string(),
|
|
detail,
|
|
}
|
|
}
|
|
|
|
fn trash_manifests(all_homes: bool) -> Result<Vec<(PathBuf, TrashManifest)>> {
|
|
let mut manifests = Vec::new();
|
|
for home in homes(all_homes)? {
|
|
let root = home.path.join("cdxs-trash");
|
|
if !root.exists() {
|
|
continue;
|
|
}
|
|
for entry in
|
|
fs::read_dir(&root).with_context(|| format!("读取垃圾箱失败: {}", root.display()))?
|
|
{
|
|
let entry = entry?;
|
|
let manifest_path = entry.path().join("manifest.json");
|
|
if !manifest_path.exists() {
|
|
continue;
|
|
}
|
|
let content = fs::read_to_string(&manifest_path)
|
|
.with_context(|| format!("读取 manifest 失败: {}", manifest_path.display()))?;
|
|
let manifest: TrashManifest =
|
|
serde_json::from_str(&content).context("解析 manifest 失败")?;
|
|
manifests.push((manifest_path, manifest));
|
|
}
|
|
}
|
|
Ok(manifests)
|
|
}
|
|
|
|
fn read_trash_entries(home: &HomeTarget) -> Result<Vec<TrashEntry>> {
|
|
let mut out = Vec::new();
|
|
let root = home.path.join("cdxs-trash");
|
|
if !root.exists() {
|
|
return Ok(out);
|
|
}
|
|
for entry in
|
|
fs::read_dir(&root).with_context(|| format!("读取垃圾箱失败: {}", root.display()))?
|
|
{
|
|
let entry = entry?;
|
|
let manifest_path = entry.path().join("manifest.json");
|
|
if !manifest_path.exists() {
|
|
continue;
|
|
}
|
|
let content = fs::read_to_string(&manifest_path)
|
|
.with_context(|| format!("读取 manifest 失败: {}", manifest_path.display()))?;
|
|
let manifest: TrashManifest =
|
|
serde_json::from_str(&content).context("解析 manifest 失败")?;
|
|
out.push(TrashEntry {
|
|
home_name: manifest.home_name,
|
|
home_path: manifest.home_path,
|
|
session_id: manifest.session_id,
|
|
title: manifest.thread.title,
|
|
cwd: manifest.thread.cwd,
|
|
deleted_at: manifest.deleted_at,
|
|
trash_dir: entry.path().to_string_lossy().to_string(),
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn homes(all_homes: bool) -> Result<Vec<HomeTarget>> {
|
|
let default_home = paths::codex_home(None)?;
|
|
if !all_homes {
|
|
return Ok(vec![HomeTarget {
|
|
name: "default".to_string(),
|
|
path: default_home,
|
|
}]);
|
|
}
|
|
|
|
let store = Store::load(&default_home)?;
|
|
let mut seen = HashSet::new();
|
|
let mut result = Vec::new();
|
|
// De-duplicate paths because multiple names can point to the same home.
|
|
for home in &store.homes {
|
|
let path = paths::expand_home(PathBuf::from(&home.path));
|
|
let key = path.to_string_lossy().to_string();
|
|
if seen.insert(key) {
|
|
result.push(HomeTarget {
|
|
name: home.name.clone(),
|
|
path,
|
|
});
|
|
}
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
fn thread_from_row(row: &Row<'_>) -> rusqlite::Result<ThreadRowData> {
|
|
Ok(ThreadRowData {
|
|
id: row.get(0)?,
|
|
rollout_path: row.get(1)?,
|
|
created_at: row.get(2)?,
|
|
updated_at: row.get(3)?,
|
|
source: row.get(4)?,
|
|
model_provider: row.get(5)?,
|
|
cwd: row.get(6)?,
|
|
title: row.get(7)?,
|
|
sandbox_policy: row.get(8)?,
|
|
approval_mode: row.get(9)?,
|
|
tokens_used: row.get(10)?,
|
|
has_user_event: row.get(11)?,
|
|
archived: row.get(12)?,
|
|
archived_at: row.get(13)?,
|
|
git_sha: row.get(14)?,
|
|
git_branch: row.get(15)?,
|
|
git_origin_url: row.get(16)?,
|
|
cli_version: row.get(17)?,
|
|
first_user_message: row.get(18)?,
|
|
agent_nickname: row.get(19)?,
|
|
agent_role: row.get(20)?,
|
|
memory_mode: row.get(21)?,
|
|
model: row.get(22)?,
|
|
reasoning_effort: row.get(23)?,
|
|
agent_path: row.get(24)?,
|
|
created_at_ms: row.get(25)?,
|
|
updated_at_ms: row.get(26)?,
|
|
})
|
|
}
|
|
|
|
fn insert_thread(conn: &Connection, row: &ThreadRowData) -> Result<()> {
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO threads (
|
|
id, rollout_path, created_at, updated_at, source, model_provider, cwd, title,
|
|
sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at,
|
|
git_sha, git_branch, git_origin_url, cli_version, first_user_message,
|
|
agent_nickname, agent_role, memory_mode, model, reasoning_effort, agent_path,
|
|
created_at_ms, updated_at_ms
|
|
) VALUES (
|
|
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
|
|
?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27
|
|
)",
|
|
params![
|
|
row.id,
|
|
row.rollout_path,
|
|
row.created_at,
|
|
row.updated_at,
|
|
row.source,
|
|
row.model_provider,
|
|
row.cwd,
|
|
row.title,
|
|
row.sandbox_policy,
|
|
row.approval_mode,
|
|
row.tokens_used,
|
|
row.has_user_event,
|
|
row.archived,
|
|
row.archived_at,
|
|
row.git_sha,
|
|
row.git_branch,
|
|
row.git_origin_url,
|
|
row.cli_version,
|
|
row.first_user_message,
|
|
row.agent_nickname,
|
|
row.agent_role,
|
|
row.memory_mode,
|
|
row.model,
|
|
row.reasoning_effort,
|
|
row.agent_path,
|
|
row.created_at_ms,
|
|
row.updated_at_ms,
|
|
],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn summary_from_thread(home: &HomeTarget, row: &ThreadRowData) -> SessionSummary {
|
|
SessionSummary {
|
|
home_name: home.name.clone(),
|
|
home_path: home.path.to_string_lossy().to_string(),
|
|
id: row.id.clone(),
|
|
title: row.title.clone(),
|
|
cwd: row.cwd.clone(),
|
|
updated_at_ms: row
|
|
.updated_at_ms
|
|
.or(row.updated_at.map(|value| value * 1000)),
|
|
tokens_used: row.tokens_used.unwrap_or_default(),
|
|
rollout_path: row.rollout_path.clone(),
|
|
archived: row.archived.unwrap_or_default() != 0,
|
|
}
|
|
}
|
|
|
|
fn rollout_stats(path: &Path) -> Result<(Option<u64>, Option<usize>, Option<TokenUsage>)> {
|
|
if !path.exists() {
|
|
return Ok((None, None, None));
|
|
}
|
|
let metadata = fs::metadata(path)
|
|
.with_context(|| format!("读取 rollout 元数据失败: {}", path.display()))?;
|
|
let content = fs::read_to_string(path)
|
|
.with_context(|| format!("读取 rollout 失败: {}", path.display()))?;
|
|
let mut usage = None;
|
|
for line in content.lines() {
|
|
// Keep the last token usage record, which represents the latest model
|
|
// accounting in the rollout.
|
|
if let Ok(value) = serde_json::from_str::<Value>(line) {
|
|
if let Some(next) = find_token_usage(&value) {
|
|
usage = Some(next);
|
|
}
|
|
}
|
|
}
|
|
Ok((Some(metadata.len()), Some(content.lines().count()), usage))
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct TokenUsage {
|
|
total_tokens: Option<i64>,
|
|
input_tokens: Option<i64>,
|
|
output_tokens: Option<i64>,
|
|
}
|
|
|
|
fn find_token_usage(value: &Value) -> Option<TokenUsage> {
|
|
match value {
|
|
Value::Object(map) => {
|
|
if let Some(total) = map.get("total_token_usage") {
|
|
return Some(TokenUsage {
|
|
total_tokens: total.get("total_tokens").and_then(Value::as_i64),
|
|
input_tokens: total.get("input_tokens").and_then(Value::as_i64),
|
|
output_tokens: total.get("output_tokens").and_then(Value::as_i64),
|
|
});
|
|
}
|
|
for child in map.values() {
|
|
if let Some(usage) = find_token_usage(child) {
|
|
return Some(usage);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
Value::Array(items) => items.iter().find_map(find_token_usage),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn line_session_id(line: &str) -> Option<String> {
|
|
serde_json::from_str::<Value>(line)
|
|
.ok()
|
|
.and_then(|value| value.get("id").and_then(Value::as_str).map(str::to_string))
|
|
}
|
|
|
|
fn state_db_path(home: &Path) -> PathBuf {
|
|
home.join("state_5.sqlite")
|
|
}
|
|
|
|
fn resolve_rollout_path(home: &Path, rollout_path: &str) -> PathBuf {
|
|
let path = PathBuf::from(rollout_path);
|
|
if path.is_absolute() {
|
|
path
|
|
} else {
|
|
home.join(path)
|
|
}
|
|
}
|
|
|
|
fn format_time(ms: Option<i64>) -> String {
|
|
let Some(ms) = ms else {
|
|
return "-".to_string();
|
|
};
|
|
DateTime::<Utc>::from_timestamp_millis(ms)
|
|
.map(|value| value.format("%Y-%m-%d %H:%M:%S").to_string())
|
|
.unwrap_or_else(|| ms.to_string())
|
|
}
|
|
|
|
fn opt_i64(value: Option<i64>) -> String {
|
|
value
|
|
.map(|value| value.to_string())
|
|
.unwrap_or_else(|| "-".to_string())
|
|
}
|
|
|
|
fn opt_u64(value: Option<u64>) -> String {
|
|
value
|
|
.map(|value| value.to_string())
|
|
.unwrap_or_else(|| "-".to_string())
|
|
}
|
|
|
|
fn safe_name(value: &str) -> String {
|
|
value
|
|
.chars()
|
|
.map(|ch| {
|
|
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
|
ch
|
|
} else {
|
|
'_'
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn shorten(value: &str, width: usize) -> String {
|
|
if value.chars().count() <= width {
|
|
return value.to_string();
|
|
}
|
|
if width <= 1 {
|
|
return "...".to_string();
|
|
}
|
|
let mut out = value
|
|
.chars()
|
|
.take(width.saturating_sub(3))
|
|
.collect::<String>();
|
|
out.push_str("...");
|
|
out
|
|
}
|