feat(db): rebuild Codex usage on upgrade and via maintenance action

Schema v16 wipes codex_session detail rows, _codex_session rollups and
Codex rollout cursors inside the migration savepoint (cursor deletion
uses pure shape matching so CODEX_HOME drift cannot orphan cursors);
the next session sync re-imports history from source JSONL under the
corrected importer. Fresh installs traverse the same branch as a no-op.

Add a manual "Rebuild Codex usage" maintenance action (single-flight,
hard-fail backup before reset, unconditional refresh notification even
when reimport is empty or fails after reset) with a destructive confirm
dialog, result toast and four-locale strings. Historical proxy-side
duplicate rows are intentionally left untouched; history whose source
JSONL was already deleted cannot be reconstructed.
This commit is contained in:
Jason
2026-07-20 12:18:47 +08:00
parent c9ac6efd69
commit eff1e0ccfc
11 changed files with 258 additions and 1 deletions
+12
View File
@@ -5,6 +5,18 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixed
- **Codex Session Usage Rebuild and Fork Deduplication**: Codex fork/subagent rollouts now strip copied parent token history by matching explicit parent IDs against the parent rollout's pre-fork token signatures. Proxy usage logging also derives stable, provider-scoped response IDs and is idempotent at the final database write. Session importers are serialized and send one refresh notification per sync.
### Upgrade notes
- Schema v16 automatically rebuilds only `codex_session` usage. A pre-migration database backup is stored under `backups/`, but history whose source JSONL was already deleted cannot be reconstructed.
- Fork files whose parent rollout is missing are deferred and reported instead of guessed; restoring the parent log and using “Rebuild Codex Usage” imports them later.
- Existing historical proxy-source duplicate rows are not removed by this migration; the idempotent logger prevents future duplicates.
## [3.17.0] - 2026-07-13
Development since v3.16.5 is headlined by project profiles — named snapshots of provider/MCP/Skills/prompt state, switchable per scope from a new header switcher or the tray (schema v12) — and a deep Codex push: official ChatGPT-subscription sessions can now route through the local proxy takeover with a corrected client identity, gpt-5.6 lands across context-window injection and Sol/Terra/Luna pricing with 1.25× cache-write rates, and a native Anthropic Messages upstream joins the Codex format options. A proxy-correctness wave makes the Responses↔Anthropic bridges fail closed and round-trip reasoning/tool results losslessly, strengthens prompt-cache breakpoint injection, and fixes cache-write accounting across historical token semantics (schema v13); a config.toml hardening batch stops deleted MCP servers from resurrecting, fails MCP sync closed on unparseable files, extends switch-time common-config autosync to Codex, and moves the common-config merge to backend toml_edit. Usage tooling gains Zhipu team-plan quota queries, Codex sub-agent and free-plan accounting, and transient-failure retry, while Kimi For Coding's 256K window finally takes effect — rounded out by a Codex default-model form field, renamed-session titles, OpenCode form and live-sync fixes, and preset updates (SudoCode sponsorship, LongCat-2.0, GPT-5.6 defaults, Hunyuan Hy3 pricing).
+59
View File
@@ -289,6 +289,35 @@ pub async fn sync_session_usage(
.map_err(|error| AppError::Message(format!("会话用量同步任务失败: {error}")))
}
/// Codex reset 成功后,无论重导是否导入新行或返回错误,都必须通知前端刷新。
/// 调用方应只在 reset 成功后调用,避免把未发生的数据变更误报为重建完成。
fn finish_codex_rebuild(
result: Result<crate::services::session_usage::SessionSyncResult, AppError>,
) -> Result<crate::services::session_usage::SessionSyncResult, AppError> {
crate::usage_events::notify_log_recorded();
result
}
/// 备份数据库后,仅重建 Codex session 用量。锁覆盖 backup → reset → import
/// 整个序列,避免后台同步在清理和重导之间插入数据。
#[tauri::command]
pub async fn rebuild_codex_usage(
state: State<'_, AppState>,
) -> Result<crate::services::session_usage::SessionSyncResult, AppError> {
let db = state.db.clone();
let _guard = crate::services::session_usage::session_sync_mutex()
.lock()
.await;
tauri::async_runtime::spawn_blocking(move || {
db.backup_database_file()?;
db.reset_codex_usage()?;
let result = crate::services::session_usage_codex::sync_codex_usage(&db);
finish_codex_rebuild(result)
})
.await
.map_err(|error| AppError::Message(format!("Codex 用量重建任务失败: {error}")))?
}
/// 获取数据来源分布
#[tauri::command]
pub fn get_usage_data_sources(
@@ -308,3 +337,33 @@ pub struct ModelPricingInfo {
pub cache_read_cost_per_million: String,
pub cache_creation_cost_per_million: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codex_rebuild_notifies_when_reimport_is_empty() {
crate::usage_events::take_test_notify_count();
let result = finish_codex_rebuild(Ok(
crate::services::session_usage::SessionSyncResult::default(),
))
.expect("空重导应成功");
assert_eq!(result.imported, 0);
assert_eq!(crate::usage_events::take_test_notify_count(), 1);
}
#[test]
fn codex_rebuild_notifies_when_reimport_fails_after_reset() {
crate::usage_events::take_test_notify_count();
let result = finish_codex_rebuild(Err(AppError::Message(
"synthetic reimport failure".to_string(),
)));
assert!(result.is_err());
assert_eq!(crate::usage_events::take_test_notify_count(), 1);
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ use std::sync::Mutex;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 15;
pub(crate) const SCHEMA_VERSION: i32 = 16;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+53
View File
@@ -506,6 +506,11 @@ impl Database {
Self::migrate_v14_to_v15(conn)?;
Self::set_user_version(conn, 15)?;
}
15 => {
log::info!("迁移数据库从 v15 到 v16(重建 Codex 会话用量)");
Self::migrate_v15_to_v16(conn)?;
Self::set_user_version(conn, 16)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -1510,6 +1515,14 @@ impl Database {
Ok(())
}
/// v15 -> v16: remove Codex session rows and cursors so startup sync can
/// rebuild them with fork-history alignment. Must stay connection-level:
/// schema migration already owns the Database connection mutex.
fn migrate_v15_to_v16(conn: &Connection) -> Result<(), AppError> {
let codex_dir = crate::codex_config::get_codex_config_dir();
crate::services::session_usage_codex::reset_codex_usage_on_conn(conn, &codex_dir)
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
@@ -3025,4 +3038,44 @@ mod tests {
Ok(())
}
#[test]
fn migrate_v15_to_v16_resets_only_codex_session_usage() -> Result<(), AppError> {
let conn = Connection::open_in_memory()?;
Database::create_tables_on_conn(&conn)?;
conn.execute_batch(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model, input_tokens,
output_tokens, cache_read_tokens, latency_ms, status_code,
created_at, data_source
) VALUES
('codex-row', '_codex_session', 'codex', 'gpt', 1, 1, 0, 0, 200, 1, 'codex_session'),
('gemini-row', '_gemini_session', 'gemini', 'gemini', 1, 1, 0, 0, 200, 1, 'gemini_session');
INSERT INTO usage_daily_rollups (date, app_type, provider_id, model)
VALUES
('2026-07-10', 'codex', '_codex_session', 'gpt'),
('2026-07-10', 'gemini', '_gemini_session', 'gemini');
INSERT INTO session_log_sync
(file_path, last_modified, last_line_offset, last_synced_at)
VALUES
('/old/sessions/rollout-old-00000000-0000-4000-8000-000000000001.jsonl', 1, 1, 1),
('/gemini/tmp/session-123.json', 1, 1, 1);",
)?;
Database::set_user_version(&conn, 15)?;
Database::apply_schema_migrations_on_conn(&conn)?;
assert_eq!(Database::get_user_version(&conn)?, 16);
let counts: (i64, i64, i64, i64) = conn.query_row(
"SELECT
(SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'codex_session'),
(SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'gemini_session'),
(SELECT COUNT(*) FROM usage_daily_rollups WHERE provider_id = '_codex_session'),
(SELECT COUNT(*) FROM session_log_sync)",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)?;
assert_eq!(counts, (0, 1, 0, 1));
Ok(())
}
}
+1
View File
@@ -1522,6 +1522,7 @@ pub fn run() {
commands::check_provider_limits,
// Session usage sync
commands::sync_session_usage,
commands::rebuild_codex_usage,
commands::get_usage_data_sources,
// Stream health check
commands::stream_check_provider,
+84
View File
@@ -19,6 +19,8 @@ import {
RefreshCw,
Coins,
LayoutGrid,
DatabaseBackup,
Loader2,
} from "lucide-react";
import { ProviderIcon } from "@/components/ProviderIcon";
import {
@@ -43,6 +45,10 @@ import { getLocaleFromLanguage } from "./format";
import { getUsageRangePresetLabel, resolveUsageRange } from "@/lib/usageRange";
import { UsageDateRangePicker } from "./UsageDateRangePicker";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { usageApi } from "@/lib/api/usage";
import { toast } from "sonner";
const APP_FILTER_OPTIONS: AppTypeFilter[] = ["all", ...KNOWN_APP_TYPES];
@@ -94,6 +100,8 @@ export function UsageDashboard({
const [refreshIntervalMs, setRefreshIntervalMs] = useState(() =>
normalizeRefreshInterval(savedRefreshIntervalMs),
);
const [showRebuildConfirm, setShowRebuildConfirm] = useState(false);
const [rebuildingCodex, setRebuildingCodex] = useState(false);
useEffect(() => {
setRefreshIntervalMs(normalizeRefreshInterval(savedRefreshIntervalMs));
@@ -138,6 +146,34 @@ export function UsageDashboard({
}
};
const rebuildCodexUsage = async () => {
setShowRebuildConfirm(false);
setRebuildingCodex(true);
try {
const result = await usageApi.rebuildCodexUsage();
await queryClient.invalidateQueries({ queryKey: usageKeys.all });
const message = t("usage.rebuildCodex.completed", {
imported: result.imported,
errors: result.errors.length,
suspected: result.suspectedDuplicates,
deferred: result.deferredFiles,
});
if (result.errors.length > 0 || result.deferredFiles > 0) {
toast.warning(message);
} else {
toast.success(message);
}
} catch (error) {
toast.error(
t("usage.rebuildCodex.failed", {
error: String(error),
}),
);
} finally {
setRebuildingCodex(false);
}
};
const language = i18n.resolvedLanguage || i18n.language || "en";
const locale = getLocaleFromLanguage(language);
const resolvedRange = useMemo(() => resolveUsageRange(range), [range]);
@@ -429,7 +465,55 @@ export function UsageDashboard({
<PricingConfigPanel />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="maintenance"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<DatabaseBackup className="h-5 w-5 text-orange-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("usage.rebuildCodex.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("usage.rebuildCodex.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<div className="flex items-center justify-between gap-4 rounded-lg border border-destructive/20 bg-destructive/5 p-4">
<p className="text-sm text-muted-foreground">
{t("usage.rebuildCodex.warning")}
</p>
<Button
variant="destructive"
disabled={rebuildingCodex}
onClick={() => setShowRebuildConfirm(true)}
className="shrink-0"
>
{rebuildingCodex ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<DatabaseBackup className="mr-2 h-4 w-4" />
)}
{t("usage.rebuildCodex.action")}
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<ConfirmDialog
isOpen={showRebuildConfirm}
title={t("usage.rebuildCodex.confirmTitle")}
message={t("usage.rebuildCodex.confirmMessage")}
confirmText={t("usage.rebuildCodex.confirmAction")}
variant="destructive"
onConfirm={() => void rebuildCodexUsage()}
onCancel={() => setShowRebuildConfirm(false)}
/>
</motion.div>
);
}
+11
View File
@@ -1519,6 +1519,17 @@
"upToDate": "Session logs are up to date",
"failed": "Session sync failed"
},
"rebuildCodex": {
"title": "Codex Usage Maintenance",
"description": "Rebuild Codex session usage from local rollout logs",
"warning": "The database is backed up first. History whose source JSONL has been deleted cannot be re-imported.",
"action": "Rebuild Codex Usage",
"confirmTitle": "Rebuild Codex usage?",
"confirmMessage": "This clears existing Codex session details and rollups, then imports them again from local Codex logs.\n\nHistory with deleted logs will be lost. Forks whose parent rollout is missing will be deferred.",
"confirmAction": "Back Up and Rebuild",
"completed": "Rebuild complete: {{imported}} imported, {{errors}} errors, {{suspected}} suspected duplicates, {{deferred}} deferred files",
"failed": "Codex usage rebuild failed: {{error}}"
},
"totalRecords": "{{total}} records total",
"goToPage": "Go",
"pageInputPlaceholder": "Page",
+11
View File
@@ -1519,6 +1519,17 @@
"upToDate": "セッションログは最新です",
"failed": "セッション同期に失敗しました"
},
"rebuildCodex": {
"title": "Codex 使用量メンテナンス",
"description": "ローカルの rollout ログから Codex セッション使用量を再構築します",
"warning": "最初にデータベースを自動バックアップします。元の JSONL が削除済みの履歴は再インポートできません。",
"action": "Codex 使用量を再構築",
"confirmTitle": "Codex 使用量を再構築しますか?",
"confirmMessage": "既存の Codex セッション明細と集計を削除し、ローカルの Codex ログから再インポートします。\n\n削除済みログの履歴は失われます。親 rollout がない fork は保留されます。",
"confirmAction": "バックアップして再構築",
"completed": "再構築完了:{{imported}} 件インポート、{{errors}} 件エラー、重複候補 {{suspected}} 件、保留ファイル {{deferred}} 件",
"failed": "Codex 使用量の再構築に失敗しました:{{error}}"
},
"totalRecords": "全 {{total}} 件",
"goToPage": "移動",
"pageInputPlaceholder": "ページ",
+11
View File
@@ -1491,6 +1491,17 @@
"upToDate": "工作階段日誌已是最新",
"failed": "工作階段同步失敗"
},
"rebuildCodex": {
"title": "Codex 用量維護",
"description": "從本機 rollout 日誌重新建構 Codex 工作階段用量",
"warning": "重建前會自動備份資料庫。若來源 JSONL 已刪除,對應歷史將無法重新匯入。",
"action": "重建 Codex 用量",
"confirmTitle": "確認重建 Codex 用量",
"confirmMessage": "此操作會清除現有 Codex 工作階段明細與彙總,再從本機 Codex 日誌重新匯入。\n\n已刪除日誌對應的歷史將遺失;缺少父 rollout 的 fork 會暫緩匯入。",
"confirmAction": "備份並重建",
"completed": "重建完成:匯入 {{imported}} 筆,錯誤 {{errors}},疑似重複 {{suspected}},暫緩檔案 {{deferred}}",
"failed": "Codex 用量重建失敗:{{error}}"
},
"totalRecords": "共 {{total}} 筆紀錄",
"goToPage": "跳轉",
"pageInputPlaceholder": "頁碼",
+11
View File
@@ -1519,6 +1519,17 @@
"upToDate": "会话日志已是最新",
"failed": "会话同步失败"
},
"rebuildCodex": {
"title": "Codex 用量维护",
"description": "从本地 rollout 日志重新构建 Codex 会话用量",
"warning": "重建前会自动备份数据库。若源 JSONL 已删除,对应历史将无法重新导入。",
"action": "重建 Codex 用量",
"confirmTitle": "确认重建 Codex 用量",
"confirmMessage": "此操作会清除现有 Codex 会话明细与汇总,再从本地 Codex 日志重新导入。\n\n已删除日志对应的历史将丢失;缺少父 rollout 的 fork 会暂缓导入。",
"confirmAction": "备份并重建",
"completed": "重建完成:导入 {{imported}} 条,错误 {{errors}},疑似重复 {{suspected}},暂缓文件 {{deferred}}",
"failed": "Codex 用量重建失败:{{error}}"
},
"totalRecords": "共 {{total}} 条记录",
"goToPage": "跳转",
"pageInputPlaceholder": "页码",
+4
View File
@@ -180,6 +180,10 @@ export const usageApi = {
return invoke("sync_session_usage");
},
rebuildCodexUsage: async (): Promise<SessionSyncResult> => {
return invoke("rebuild_codex_usage");
},
getDataSourceBreakdown: async (): Promise<DataSourceSummary[]> => {
return invoke("get_usage_data_sources");
},