mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-18 05:59:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c2cb51c59 | ||
|
|
b796b4d2cd | ||
|
|
16f64e3982 | ||
|
|
4772287fe1 | ||
|
|
163285cf32 | ||
|
|
d5f4a6572b | ||
|
|
bca9d0af55 | ||
|
|
ed5bdad63f | ||
|
|
47a13c8674 | ||
|
|
5dd375ce97 | ||
|
|
16380882a0 | ||
|
|
9c75c4295d | ||
|
|
b0585e61be | ||
|
|
f2bf8bc73f | ||
|
|
96b24f7b81 | ||
|
|
392b2b1bd5 | ||
|
|
0e28260197 | ||
|
|
edd399c079 | ||
|
|
460f629925 | ||
|
|
3970bbeb10 | ||
|
|
999b66a901 | ||
|
|
46fece2f44 |
@@ -177,7 +177,7 @@ jobs:
|
||||
- name: Windows x64
|
||||
platform: windows-latest
|
||||
macos: false
|
||||
args: --bundles msi
|
||||
args: --bundles nsis
|
||||
asset_pattern: Codex-X-[version]-windows-x64[ext]
|
||||
|
||||
steps:
|
||||
@@ -316,7 +316,7 @@ jobs:
|
||||
releaseAssetNamePattern: ${{ matrix.asset_pattern }}
|
||||
uploadUpdaterJson: true
|
||||
uploadUpdaterSignatures: true
|
||||
updaterJsonPreferNsis: false
|
||||
updaterJsonPreferNsis: true
|
||||
retryAttempts: 3
|
||||
|
||||
- name: Verify updater signatures were generated
|
||||
@@ -328,6 +328,14 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Test Windows installation and legacy MSI migration
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installers = @(Get-ChildItem apps/desktop/src-tauri/target/release/bundle/nsis -Filter '*.exe')
|
||||
if ($installers.Count -ne 1) { throw 'Expected one NSIS installer.' }
|
||||
./scripts/windows-install-smoke.ps1 -Installer $installers[0].FullName
|
||||
|
||||
- name: Verify signed and notarized macOS bundles
|
||||
if: matrix.macos
|
||||
run: |
|
||||
@@ -355,11 +363,12 @@ jobs:
|
||||
rm -rf "$portable_root"
|
||||
mkdir -p "$portable_dir"
|
||||
cp apps/desktop/src-tauri/target/release/codex-x.exe "$portable_dir/Codex-X.exe"
|
||||
cp THIRD_PARTY_NOTICES.md "$portable_dir/THIRD_PARTY_NOTICES.md"
|
||||
touch "$portable_dir/Codex-X.portable"
|
||||
cat > "$portable_dir/README-portable.txt" <<'EOF'
|
||||
Codex-X Windows Portable
|
||||
|
||||
Double click Codex-X.exe to run. No MSI installation is required.
|
||||
Double click Codex-X.exe to run. No installation is required.
|
||||
The portable edition uses manual downloads instead of in-app installation.
|
||||
Keep Codex-X.portable next to Codex-X.exe so the app can identify this edition.
|
||||
Microsoft Edge WebView2 Runtime is required; Windows 10/11 usually already includes it.
|
||||
@@ -434,6 +443,7 @@ jobs:
|
||||
--repository "$GITHUB_REPOSITORY" \
|
||||
--release-tag "$RELEASE_TAG" \
|
||||
--rewrite-download-urls \
|
||||
--migrate-windows-to-nsis \
|
||||
--require-signature-assets
|
||||
gh release upload "$RELEASE_TAG" latest.json --clobber
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Windows installer verification
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'codex/windows-updater-*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: windows-installer-check-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
windows-installer:
|
||||
name: Verify Windows update migration
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
|
||||
with:
|
||||
version: 10.26.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
|
||||
with:
|
||||
workspaces: apps/desktop/src-tauri
|
||||
cache-on-failure: true
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --dir apps/desktop typecheck
|
||||
- run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked -- --test-threads=1
|
||||
- name: Build test installer without publishing or release signing credentials
|
||||
run: |
|
||||
Set-Content -Path "$env:RUNNER_TEMP\installer-check.json" -Value '{"bundle":{"createUpdaterArtifacts":false}}' -Encoding utf8
|
||||
pnpm --dir apps/desktop tauri build --debug --bundles nsis --config "$env:RUNNER_TEMP\installer-check.json" --ci
|
||||
- name: Test real MSI migration and subsequent updates
|
||||
run: |
|
||||
$installers = @(Get-ChildItem apps/desktop/src-tauri/target/debug/bundle/nsis -Filter '*.exe')
|
||||
if ($installers.Count -ne 1) { throw 'Expected one Windows installer.' }
|
||||
./scripts/windows-install-smoke.ps1 -Installer $installers[0].FullName
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Windows MSI API probe
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['codex/windows-updater-*']
|
||||
paths: ['scripts/windows-msi-probe.ps1', '.github/workflows/windows-msi-probe.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
msi-probe:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
- shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path "${env:ProgramFiles(x86)}\NSIS\makensis.exe")) {
|
||||
choco install nsis --yes --no-progress
|
||||
}
|
||||
- shell: pwsh
|
||||
run: ./scripts/windows-msi-probe.ps1
|
||||
+23
-1
@@ -2,7 +2,29 @@
|
||||
|
||||
记录 Codex-X 的新功能、体验改进和问题修复。
|
||||
|
||||
## [Unreleased]
|
||||
## [v0.3.23] - 2026-10-01
|
||||
|
||||
- 会话管理新增工作文件夹侧栏、文件夹与会话置顶,并使用虚拟列表减少大量会话的渲染开销。
|
||||
- 会话日志改为流式读取和磁盘快照处理,大日志及超长单条记录也能同步;读取、写入或复查失败时明确报错并执行安全回滚,避免日志、数据库索引与同步状态不一致。
|
||||
- 修复文件夹路径大小写混淆导致显示错误会话、置顶联动和缓存串用的问题,按实际文件系统身份区分目录或合并同目录别名。
|
||||
- 置顶按 Codex 配置目录分别保存,文件夹暂时不可读或重建后按原始路径保留置顶。旧 PR 预览版的 v1 置顶缓存不自动迁移,需重新置顶。
|
||||
|
||||
## [v0.3.22] - 2026-09-30
|
||||
|
||||
- 修复第三方模型被错误识别为“仅文本”而无法输入图片的问题,并自动升级旧版生成的模型目录。需供应商模型本身支持图片,更新菜单后重启 Codex 生效。
|
||||
- 供应商卡片支持上下拖动排序并自动保存,重启后保留顺序;也可聚焦左侧手柄后使用上下箭头移动。
|
||||
- 在供应商卡片显示已选模型名称,设置映射别名时同时保留真实模型 ID。
|
||||
- 补充 Ultra 思考强度的 Codex 设置提示,并修复旧模型目录遗漏图片或完整思考强度的问题。
|
||||
|
||||
## [v0.3.21] - 2026-09-23
|
||||
|
||||
- 优化 Windows 在线更新,缩短安装等待;旧版可自动迁移,正常更新无需重启电脑,账号和配置保留。
|
||||
|
||||
## [v0.3.20] - 2026-09-18
|
||||
|
||||
- 新增「设置 → 路由与故障转移」:可管理本地路由、安排供应商优先顺序,并在服务异常时自动切换;支持调整超时和重试参数,官方登录也可使用本地路由。
|
||||
- 修复添加或切换第三方供应商时,MCP、桌面设置等配置丢失的问题。
|
||||
- 修复内部任务被误同步,以及回退后的正常会话被误报异常的问题。
|
||||
|
||||
## [v0.3.18] - 2026-09-17
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>跨平台使用</b></td>
|
||||
<td align="left">提供 macOS Apple Silicon / Intel、Windows MSI / 便携版和 Linux 安装包;安装版可在应用内直接下载、校验并安装更新,便携版继续使用手动下载。</td>
|
||||
<td align="left">提供 macOS Apple Silicon / Intel、Windows EXE / 便携版和 Linux 安装包;安装版可在应用内直接下载、校验并安装更新,便携版继续使用手动下载。</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -335,7 +335,7 @@ Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可
|
||||
|
||||
- macOS Apple Silicon `.dmg`
|
||||
- macOS Intel `.dmg`
|
||||
- Windows `.msi`
|
||||
- Windows `.exe`(安装到当前用户,后续更新无需管理员权限)
|
||||
- Windows Portable `.zip`
|
||||
- Linux `.deb` / `.rpm` / `.AppImage`
|
||||
- GitHub Releases 自动构建发布
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Third-party notices
|
||||
|
||||
## CC Switch
|
||||
|
||||
Source: https://github.com/farion1231/cc-switch
|
||||
|
||||
Reference commit: `06082e189d65e6d6dbadc35dacdac1ce6c79d89a`.
|
||||
|
||||
Codex-X adapts the circuit-breaker implementation and Codex routing, failover, timeout and configuration behavior from CC Switch. The blocking/local HTTP integration, application state and UI styling are adapted to this project. Relevant source files carry provenance comments.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Jason Young
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## Tauri Windows installer template
|
||||
|
||||
Source: https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle/windows/templates/installer.nsi
|
||||
|
||||
Codex-X adapts the Tauri CLI v2.11.4 NSIS template for current-user installation, verified legacy MSI migration, update handoff and installation diagnostics.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 - Present Tauri Apps Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.3.18",
|
||||
"version": "0.3.23",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
Generated
+48
-1
@@ -438,6 +438,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -490,11 +492,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.3.18"
|
||||
version = "0.3.23"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"flate2",
|
||||
"getrandom 0.2.17",
|
||||
"httparse",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"rfd",
|
||||
@@ -509,10 +514,14 @@ dependencies = [
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml_edit 0.22.27",
|
||||
"ureq",
|
||||
"windows-sys 0.61.2",
|
||||
"zip 2.4.2",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1954,6 +1963,16 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.103"
|
||||
@@ -5637,6 +5656,34 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.1.0+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.13.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codex-x"
|
||||
version = "0.3.18"
|
||||
version = "0.3.23"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
@@ -40,3 +40,12 @@ percent-encoding = "2.3"
|
||||
zip = { version = "2.2", default-features = false, features = ["deflate"] }
|
||||
rfd = { version = "=0.17.2", default-features = false, features = ["xdg-portal"] }
|
||||
tauri-plugin-single-instance = "=2.4.3"
|
||||
httparse = "1"
|
||||
getrandom = "0.2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "time", "net"] }
|
||||
flate2 = "1"
|
||||
zstd = "0.13"
|
||||
tempfile = "3.27"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"updater:allow-check",
|
||||
"updater:allow-download",
|
||||
"updater:allow-install",
|
||||
"updater:allow-download-and-install",
|
||||
"process:allow-restart"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 - Present Tauri Apps Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
const APP_DB_SCHEMA_VERSION: i64 = 5;
|
||||
const APP_DB_SCHEMA_VERSION: i64 = 8;
|
||||
|
||||
struct DatabaseInitializer {
|
||||
migration_lock: Mutex<()>,
|
||||
@@ -208,6 +208,14 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
provider_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS provider_failover (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
record_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS provider_common_config_state (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
handled_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS official_profiles (
|
||||
codex_dir TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
@@ -220,6 +228,11 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
profile_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS provider_card_orders (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
order_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
@@ -392,7 +405,17 @@ mod tests {
|
||||
PRAGMA user_version = 4;").unwrap();
|
||||
drop(legacy);
|
||||
let migrated = DatabaseInitializer::new().open_at(&path).unwrap();
|
||||
assert_eq!(schema_version(&migrated).unwrap(), 5);
|
||||
assert_eq!(schema_version(&migrated).unwrap(), APP_DB_SCHEMA_VERSION);
|
||||
assert_eq!(
|
||||
migrated
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM provider_common_config_state",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
let stored = crate::providers::list_saved_providers_on_connection(&migrated).unwrap();
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert!(stored[0].model_mappings.is_empty());
|
||||
@@ -416,6 +439,66 @@ mod tests {
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_seven_database_adds_card_order_preferences_without_changing_provider_data() {
|
||||
let path = test_db_path("provider-card-order-v8");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let legacy = initializer.open_at(&path).unwrap();
|
||||
legacy.execute_batch(
|
||||
"INSERT INTO providers
|
||||
(id, provider_name, base_url, model, api_key, created_at, updated_at)
|
||||
VALUES ('existing', 'Existing provider', 'https://fixture.test/v1', 'fixture-model',
|
||||
'fixture-key', 'original-created', 'original-updated');
|
||||
INSERT INTO official_profiles
|
||||
(codex_dir, id, provider_name, created_at, updated_at)
|
||||
VALUES ('/fixture/config', 'existing-account', 'Existing account', 'created', 'updated');
|
||||
DROP TABLE provider_card_orders;
|
||||
PRAGMA user_version = 7;",
|
||||
)
|
||||
.unwrap();
|
||||
let before = crate::providers::list_saved_providers_on_connection(&legacy).unwrap();
|
||||
drop(legacy);
|
||||
|
||||
let migrated = initializer.open_at(&path).unwrap();
|
||||
assert_eq!(schema_version(&migrated).unwrap(), APP_DB_SCHEMA_VERSION);
|
||||
assert_eq!(
|
||||
crate::providers::list_saved_providers_on_connection(&migrated).unwrap(),
|
||||
before
|
||||
);
|
||||
let timestamps: (String, String) = migrated
|
||||
.query_row(
|
||||
"SELECT created_at, updated_at FROM providers WHERE id = 'existing'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
timestamps,
|
||||
("original-created".into(), "original-updated".into())
|
||||
);
|
||||
assert_eq!(
|
||||
migrated
|
||||
.query_row(
|
||||
"SELECT provider_name FROM official_profiles WHERE id = 'existing-account'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
"Existing account"
|
||||
);
|
||||
assert_eq!(
|
||||
migrated
|
||||
.query_row("SELECT COUNT(*) FROM provider_card_orders", [], |row| row
|
||||
.get::<_, i64>(
|
||||
0
|
||||
))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
drop(migrated);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_one_database_migrates_new_metadata_tables_without_data_loss() {
|
||||
let path = test_db_path("skills-mcp-notes-migration");
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Keep downloads and verification in the signed Tauri updater, but own the
|
||||
//! Windows handoff. The stock updater exits immediately and skips our routing
|
||||
//! shutdown; it also cannot report installation progress after that exit.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tauri::{ipc::Channel, Manager, ResourceId, Webview};
|
||||
use tauri_plugin_updater::Update;
|
||||
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
#[path = "windows_update.rs"]
|
||||
mod windows;
|
||||
|
||||
static UPDATING: AtomicBool = AtomicBool::new(false);
|
||||
const DOWNLOAD_TIMEOUT_MS: u64 = 10 * 60 * 1000;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(tag = "event", content = "data")]
|
||||
pub(crate) enum AppUpdateEvent {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Started {
|
||||
content_length: Option<u64>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Progress {
|
||||
chunk_length: usize,
|
||||
},
|
||||
Verifying,
|
||||
#[allow(dead_code)] // Emitted by the Windows handoff; other platforms install in place.
|
||||
Preparing,
|
||||
Installing,
|
||||
#[allow(dead_code)]
|
||||
HandedOff,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum FailureStage {
|
||||
Download,
|
||||
Verify,
|
||||
Prepare,
|
||||
Install,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UpdateFailure {
|
||||
stage: FailureStage,
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
log_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct InstallResult {
|
||||
restart_required: bool,
|
||||
}
|
||||
|
||||
struct UpdateLease<'a>(&'a AtomicBool);
|
||||
impl<'a> UpdateLease<'a> {
|
||||
fn acquire(flag: &'a AtomicBool) -> Result<Self, UpdateFailure> {
|
||||
flag.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Prepare,
|
||||
"已有更新正在进行,请等待当前操作完成。",
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
Ok(Self(flag))
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
fn handoff(self) {
|
||||
// Never permit a second installer while the original app is exiting.
|
||||
std::mem::forget(self);
|
||||
}
|
||||
}
|
||||
impl Drop for UpdateLease<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(stage: FailureStage, message: &str, log: Option<&UpdateLog>) -> UpdateFailure {
|
||||
UpdateFailure {
|
||||
stage,
|
||||
message: message.into(),
|
||||
log_path: log.map(|log| log.path.to_string_lossy().into_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
/// This log contains only our fixed stage names and version, never URLs,
|
||||
/// headers, auth/config contents or raw network/parser error messages.
|
||||
struct UpdateLog {
|
||||
path: PathBuf,
|
||||
file: Mutex<File>,
|
||||
}
|
||||
impl UpdateLog {
|
||||
fn create(dir: &Path, version: &str) -> std::io::Result<Self> {
|
||||
let version = semver::Version::parse(version).map_err(|_| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid version")
|
||||
})?;
|
||||
fs::create_dir_all(dir)?;
|
||||
let mut nonce = [0u8; 8];
|
||||
getrandom::getrandom(&mut nonce)
|
||||
.map_err(|_| std::io::Error::other("random source unavailable"))?;
|
||||
let nonce: String = nonce.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let path = dir.join(format!(
|
||||
"update-{}-{}-{nonce}.log",
|
||||
chrono::Utc::now().format("%Y%m%d-%H%M%S"),
|
||||
std::process::id()
|
||||
));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let file = Mutex::new(options.open(&path)?);
|
||||
let log = Self { path, file };
|
||||
log.record(&format!("target_version={version}"));
|
||||
Ok(log)
|
||||
}
|
||||
fn record(&self, stage: &str) {
|
||||
if let Ok(mut file) = self.file.lock() {
|
||||
let _ = writeln!(file, "{} {stage}", chrono::Utc::now().to_rfc3339());
|
||||
let _ = file.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn timeout_ms(value: Option<u64>) -> u64 {
|
||||
value
|
||||
.unwrap_or(DOWNLOAD_TIMEOUT_MS)
|
||||
.clamp(30_000, DOWNLOAD_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/// No installer is launched until all preparation succeeds. If launch fails,
|
||||
/// restore routing while the original app and single-instance lock still live.
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
fn prepared_install<T>(
|
||||
prepare: impl FnOnce() -> Result<(), ()>,
|
||||
launch: impl FnOnce() -> Result<T, ()>,
|
||||
resume: impl FnOnce() -> Result<(), ()>,
|
||||
log: &UpdateLog,
|
||||
) -> Result<T, UpdateFailure> {
|
||||
if prepare().is_err() {
|
||||
log.record("prepare_failed");
|
||||
let resumed = resume().is_ok();
|
||||
log.record(if resumed {
|
||||
"routing_resumed"
|
||||
} else {
|
||||
"routing_resume_failed"
|
||||
});
|
||||
return Err(failure(
|
||||
FailureStage::Prepare,
|
||||
if resumed {
|
||||
"更新前未能恢复路由配置,安装尚未启动。请检查配置文件后重试。"
|
||||
} else {
|
||||
"更新前的路由清理未完成,安装尚未启动。请检查路由设置后重试。"
|
||||
},
|
||||
Some(log),
|
||||
));
|
||||
}
|
||||
match launch() {
|
||||
Ok(result) => Ok(result),
|
||||
Err(()) => {
|
||||
log.record("installer_launch_failed");
|
||||
let resumed = resume().is_ok();
|
||||
log.record(if resumed {
|
||||
"routing_resumed"
|
||||
} else {
|
||||
"routing_resume_failed"
|
||||
});
|
||||
Err(failure(
|
||||
FailureStage::Install,
|
||||
if resumed {
|
||||
"无法启动更新安装器,原有路由已恢复。请重试或打开下载页安装。"
|
||||
} else {
|
||||
"无法启动更新安装器。请检查路由设置;也可打开下载页安装。"
|
||||
},
|
||||
Some(log),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn install_app_update(
|
||||
webview: Webview,
|
||||
update_rid: ResourceId,
|
||||
on_event: Channel<AppUpdateEvent>,
|
||||
timeout: Option<u64>,
|
||||
headers: Option<Vec<(String, String)>>,
|
||||
) -> Result<InstallResult, UpdateFailure> {
|
||||
let _lease = UpdateLease::acquire(&UPDATING)?;
|
||||
let update = webview
|
||||
.resources_table()
|
||||
.get::<Update>(update_rid)
|
||||
.map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Prepare,
|
||||
"更新信息已失效,请重新检查更新。",
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
let mut update = (*update).clone();
|
||||
// The 15s metadata-check timeout must not become the package-download limit.
|
||||
update.timeout = Some(Duration::from_millis(timeout_ms(timeout)));
|
||||
if let Some(headers) = headers {
|
||||
update.headers.clear();
|
||||
for (name, value) in headers {
|
||||
let name = reqwest::header::HeaderName::from_bytes(name.as_bytes())
|
||||
.map_err(|_| failure(FailureStage::Prepare, "更新请求参数无效。", None))?;
|
||||
let value = reqwest::header::HeaderValue::from_str(&value)
|
||||
.map_err(|_| failure(FailureStage::Prepare, "更新请求参数无效。", None))?;
|
||||
update.headers.append(name, value);
|
||||
}
|
||||
}
|
||||
let dir = crate::paths::app_home()
|
||||
.map_err(|_| failure(FailureStage::Prepare, "无法读取更新日志目录。", None))?
|
||||
.join("update-logs");
|
||||
let log = UpdateLog::create(&dir, &update.version).map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Prepare,
|
||||
"无法创建更新日志,请检查文件夹权限后重试。",
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
log.record("download_started");
|
||||
let finished = AtomicBool::new(false);
|
||||
let mut started = false;
|
||||
let bytes = update
|
||||
.download(
|
||||
|chunk_length, content_length| {
|
||||
if !started {
|
||||
started = true;
|
||||
let _ = on_event.send(AppUpdateEvent::Started { content_length });
|
||||
}
|
||||
let _ = on_event.send(AppUpdateEvent::Progress { chunk_length });
|
||||
},
|
||||
|| {
|
||||
finished.store(true, Ordering::Release);
|
||||
log.record("verifying_signature");
|
||||
let _ = on_event.send(AppUpdateEvent::Verifying);
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
if finished.load(Ordering::Acquire) {
|
||||
log.record("signature_verification_failed");
|
||||
failure(
|
||||
FailureStage::Verify,
|
||||
"更新包校验未通过,安装尚未启动。请重新下载或前往下载页。",
|
||||
Some(&log),
|
||||
)
|
||||
} else {
|
||||
log.record("download_failed");
|
||||
failure(
|
||||
FailureStage::Download,
|
||||
"更新下载未完成或连接超时。请检查网络后重试。",
|
||||
Some(&log),
|
||||
)
|
||||
}
|
||||
})?;
|
||||
log.record("signature_verified");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app = webview.app_handle().clone();
|
||||
let result = install_windows(app, bytes, on_event, log).await;
|
||||
if result.is_ok() {
|
||||
_lease.handoff();
|
||||
}
|
||||
result
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = on_event.send(AppUpdateEvent::Installing);
|
||||
log.record("install_started");
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
update.install(bytes).map_err(|_| {
|
||||
log.record("install_failed");
|
||||
failure(
|
||||
FailureStage::Install,
|
||||
"安装更新未完成,请重试或前往下载页安装。",
|
||||
Some(&log),
|
||||
)
|
||||
})?;
|
||||
log.record("install_finished_restart_required");
|
||||
Ok(InstallResult {
|
||||
restart_required: true,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Install,
|
||||
"安装更新未完成,请重新打开软件后检查更新。",
|
||||
None,
|
||||
)
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
// Compile this path in host tests too: staging/Command/IPC use portable Rust,
|
||||
// so Windows handoff type errors are caught before the Windows build starts.
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
async fn install_windows(
|
||||
app: tauri::AppHandle,
|
||||
bytes: Vec<u8>,
|
||||
on_event: Channel<AppUpdateEvent>,
|
||||
log: UpdateLog,
|
||||
) -> Result<InstallResult, UpdateFailure> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
// Stage bytes before changing live routes. Only signed .exe payloads
|
||||
// from this release pipeline are supported by the new Windows flow.
|
||||
let installer = windows::StagedInstaller::create(&bytes).map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Install,
|
||||
"无法准备 Windows 更新包,请检查磁盘空间或前往下载页安装。",
|
||||
Some(&log),
|
||||
)
|
||||
})?;
|
||||
let _ = on_event.send(AppUpdateEvent::Preparing);
|
||||
log.record("preparing_route_shutdown");
|
||||
prepared_install(
|
||||
|| crate::failover::shutdown_all().map_err(|_| ()),
|
||||
|| {
|
||||
let _ = on_event.send(AppUpdateEvent::Installing);
|
||||
log.record("launching_installer");
|
||||
installer.launch(std::process::id()).map_err(|_| ())
|
||||
},
|
||||
|| crate::failover::resume_after_failed_update().map_err(|_| ()),
|
||||
&log,
|
||||
)?;
|
||||
log.record("installer_handed_off");
|
||||
let _ = on_event.send(AppUpdateEvent::HandedOff);
|
||||
// Normal Tauri exit destroys windows/tray/single-instance lock. The
|
||||
// new installer waits for this PID before touching installed files.
|
||||
app.exit(0);
|
||||
Ok(InstallResult {
|
||||
restart_required: false,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Install,
|
||||
"更新准备过程被中断,请重新打开 Codex-X 后检查更新。",
|
||||
None,
|
||||
)
|
||||
})?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
#[test]
|
||||
fn one_update_at_a_time_and_errors_release_the_guard() {
|
||||
let flag = AtomicBool::new(false);
|
||||
let first = UpdateLease::acquire(&flag).unwrap();
|
||||
assert!(UpdateLease::acquire(&flag).is_err());
|
||||
drop(first);
|
||||
assert!(UpdateLease::acquire(&flag).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_download_has_its_own_bounded_timeout() {
|
||||
assert_eq!(timeout_ms(None), 600_000);
|
||||
assert_eq!(timeout_ms(Some(15_000)), 30_000);
|
||||
assert_eq!(timeout_ms(Some(u64::MAX)), 600_000);
|
||||
assert_eq!(timeout_ms(Some(120_000)), 120_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handoff_never_launches_on_failed_prepare_and_resumes_on_launch_failure() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let log = UpdateLog::create(temp.path(), "0.4.0").unwrap();
|
||||
let calls = RefCell::new(vec![]);
|
||||
let fail = prepared_install(
|
||||
|| {
|
||||
calls.borrow_mut().push("prepare");
|
||||
Err(())
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("launch");
|
||||
Ok(())
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("resume");
|
||||
Ok(())
|
||||
},
|
||||
&log,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(fail.stage, FailureStage::Prepare);
|
||||
assert_eq!(*calls.borrow(), vec!["prepare", "resume"]);
|
||||
calls.borrow_mut().clear();
|
||||
let fail = prepared_install(
|
||||
|| {
|
||||
calls.borrow_mut().push("prepare");
|
||||
Ok(())
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("launch");
|
||||
Err::<(), _>(())
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("resume");
|
||||
Err(())
|
||||
},
|
||||
&log,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(fail.stage, FailureStage::Install);
|
||||
assert_eq!(*calls.borrow(), vec!["prepare", "launch", "resume"]);
|
||||
assert!(fail.message.contains("检查路由设置"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_handoff_does_not_resume_routing() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let log = UpdateLog::create(temp.path(), "0.4.0").unwrap();
|
||||
assert_eq!(
|
||||
prepared_install(|| Ok(()), || Ok(42), || panic!("must not resume"), &log).unwrap(),
|
||||
42
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_are_unique_and_reject_untrusted_version_text() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let first = UpdateLog::create(temp.path(), "0.4.0").unwrap();
|
||||
let second = UpdateLog::create(temp.path(), "0.4.0").unwrap();
|
||||
assert_ne!(first.path, second.path);
|
||||
assert!(UpdateLog::create(temp.path(), "0.4.0\nsecret").is_err());
|
||||
first.record("signature_verified");
|
||||
assert!(fs::read_to_string(&first.path)
|
||||
.unwrap()
|
||||
.contains("signature_verified"));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Manager, WindowEvent,
|
||||
Emitter, Manager, WindowEvent,
|
||||
};
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
@@ -123,7 +123,37 @@ pub(crate) fn handle_window_event(window: &tauri::Window, event: &WindowEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn report_failover_lifecycle_error(app: &tauri::AppHandle, title: &str, reason: &str) {
|
||||
let title = title.to_string();
|
||||
let reason = reason.to_string();
|
||||
eprintln!("{title}: {reason}");
|
||||
let _ = app.emit("provider-failover-error", &reason);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
rfd::AsyncMessageDialog::new()
|
||||
.set_title(title)
|
||||
.set_description(reason)
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_buttons(rfd::MessageButtons::Ok)
|
||||
.show()
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn handle_run_event(app: &tauri::AppHandle, event: tauri::RunEvent) {
|
||||
if let tauri::RunEvent::ExitRequested { api, .. } = &event {
|
||||
if let Err(error) = crate::failover::shutdown_all() {
|
||||
// Keep the listener alive when restoring the direct route failed.
|
||||
// Exiting here would strand Codex on a local address with no server.
|
||||
api.prevent_exit();
|
||||
restore_main_window(app);
|
||||
report_failover_lifecycle_error(
|
||||
app,
|
||||
"暂时无法退出 Codex-X",
|
||||
&format!("自动切换的连接配置还未恢复,Codex-X 将继续运行。\n\n{error}\n\n请检查配置文件是否被占用,再重试退出。"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Reopen { .. } = event {
|
||||
restore_main_window(app);
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
// Adapted from CC Switch 06082e189d65e6d6dbadc35dacdac1ce6c79d89a,
|
||||
// src-tauri/src/proxy/circuit_breaker.rs. The state transitions and counters
|
||||
// retain the upstream behavior; locks are synchronous and logs are omitted.
|
||||
//
|
||||
// MIT License
|
||||
//
|
||||
// Copyright (c) 2025 Jason Young
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
//! 熔断器模块
|
||||
//!
|
||||
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
|
||||
|
||||
use super::config::RoutingTuning;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
// The upstream state machine is retained; Codex-X forwards on bounded blocking
|
||||
// workers, so its short state locks use std rather than awaiting Tokio locks.
|
||||
struct RwLock<T>(std::sync::RwLock<T>);
|
||||
impl<T> RwLock<T> {
|
||||
fn new(value: T) -> Self {
|
||||
Self(std::sync::RwLock::new(value))
|
||||
}
|
||||
fn read(&self) -> std::sync::RwLockReadGuard<'_, T> {
|
||||
self.0.read().unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
fn write(&self) -> std::sync::RwLockWriteGuard<'_, T> {
|
||||
self.0.write().unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
/// 熔断器状态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CircuitState {
|
||||
/// 关闭状态 - 正常工作
|
||||
Closed,
|
||||
/// 打开状态 - 熔断激活,拒绝请求
|
||||
Open,
|
||||
/// 半开状态 - 尝试恢复,允许部分请求通过
|
||||
HalfOpen,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CircuitState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CircuitState::Closed => write!(f, "closed"),
|
||||
CircuitState::Open => write!(f, "open"),
|
||||
CircuitState::HalfOpen => write!(f, "half_open"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 熔断器配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CircuitBreakerConfig {
|
||||
/// 失败阈值 - 连续失败多少次后打开熔断器
|
||||
pub failure_threshold: u32,
|
||||
/// 成功阈值 - 半开状态下成功多少次后关闭熔断器
|
||||
pub success_threshold: u32,
|
||||
/// 超时时间 - 熔断器打开后多久尝试半开(秒)
|
||||
pub timeout_seconds: u64,
|
||||
/// 错误率阈值 - 错误率超过此值时打开熔断器 (0.0-1.0)
|
||||
pub error_rate_threshold: f64,
|
||||
/// 最小请求数 - 计算错误率前的最小请求数
|
||||
pub min_requests: u32,
|
||||
}
|
||||
|
||||
impl From<&RoutingTuning> for CircuitBreakerConfig {
|
||||
fn from(config: &RoutingTuning) -> Self {
|
||||
Self {
|
||||
failure_threshold: config.circuit_failure_threshold,
|
||||
success_threshold: config.circuit_success_threshold,
|
||||
timeout_seconds: config.circuit_timeout_seconds as u64,
|
||||
error_rate_threshold: config.circuit_error_rate_threshold,
|
||||
min_requests: config.circuit_min_requests,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CircuitBreakerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
failure_threshold: 4,
|
||||
success_threshold: 2,
|
||||
timeout_seconds: 60,
|
||||
error_rate_threshold: 0.6,
|
||||
min_requests: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 熔断器实例
|
||||
pub struct CircuitBreaker {
|
||||
/// 当前状态
|
||||
state: Arc<RwLock<CircuitState>>,
|
||||
/// 连续失败计数
|
||||
consecutive_failures: Arc<AtomicU32>,
|
||||
/// 连续成功计数(半开状态)
|
||||
consecutive_successes: Arc<AtomicU32>,
|
||||
/// 总请求计数
|
||||
total_requests: Arc<AtomicU32>,
|
||||
/// 失败请求计数
|
||||
failed_requests: Arc<AtomicU32>,
|
||||
/// 上次打开时间
|
||||
last_opened_at: Arc<RwLock<Option<Instant>>>,
|
||||
/// 配置(支持热更新)
|
||||
config: Arc<RwLock<CircuitBreakerConfig>>,
|
||||
/// 半开状态已放行的请求数(用于限流)
|
||||
half_open_requests: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
/// 熔断器放行结果
|
||||
///
|
||||
/// `used_half_open_permit` 表示本次放行是否占用了 HalfOpen 探测名额。
|
||||
/// 调用方应在请求结束后把该值传回 `record_success` / `record_failure` 用于正确释放名额。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AllowResult {
|
||||
pub allowed: bool,
|
||||
pub used_half_open_permit: bool,
|
||||
}
|
||||
|
||||
impl CircuitBreaker {
|
||||
/// 创建新的熔断器
|
||||
pub fn new(config: CircuitBreakerConfig) -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(CircuitState::Closed)),
|
||||
consecutive_failures: Arc::new(AtomicU32::new(0)),
|
||||
consecutive_successes: Arc::new(AtomicU32::new(0)),
|
||||
total_requests: Arc::new(AtomicU32::new(0)),
|
||||
failed_requests: Arc::new(AtomicU32::new(0)),
|
||||
last_opened_at: Arc::new(RwLock::new(None)),
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
half_open_requests: Arc::new(AtomicU32::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新熔断器配置(热更新,不重置状态)
|
||||
pub fn update_config(&self, new_config: CircuitBreakerConfig) {
|
||||
*self.config.write() = new_config;
|
||||
}
|
||||
|
||||
/// 判断当前 Provider 是否“可被纳入候选链路”
|
||||
///
|
||||
/// 这个方法不会占用 HalfOpen 探测名额,仅用于路由选择阶段的“可用性判断”:
|
||||
/// - Closed / HalfOpen:可用(返回 true)
|
||||
/// - Open:若超时到达则切到 HalfOpen 并返回 true,否则返回 false
|
||||
///
|
||||
/// 注意:真正发起请求前仍需调用 `allow_request()` 来获取 HalfOpen 探测名额,
|
||||
/// 并在请求结束后通过 `record_success()` / `record_failure()` 释放。
|
||||
pub fn is_available(&self) -> bool {
|
||||
let state = *self.state.read();
|
||||
let config = self.config.read();
|
||||
|
||||
match state {
|
||||
CircuitState::Closed | CircuitState::HalfOpen => true,
|
||||
CircuitState::Open => {
|
||||
if let Some(opened_at) = *self.last_opened_at.read() {
|
||||
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_half_open();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否允许请求通过
|
||||
pub fn allow_request(&self) -> AllowResult {
|
||||
let state = *self.state.read();
|
||||
|
||||
match state {
|
||||
CircuitState::Closed => AllowResult {
|
||||
allowed: true,
|
||||
used_half_open_permit: false,
|
||||
},
|
||||
CircuitState::Open => {
|
||||
let config = self.config.read();
|
||||
// 检查是否应该尝试半开
|
||||
if let Some(opened_at) = *self.last_opened_at.read() {
|
||||
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_half_open();
|
||||
|
||||
// 转换后按当前状态决定是否需要获取 HalfOpen 探测名额
|
||||
let current_state = *self.state.read();
|
||||
return match current_state {
|
||||
CircuitState::Closed => AllowResult {
|
||||
allowed: true,
|
||||
used_half_open_permit: false,
|
||||
},
|
||||
CircuitState::HalfOpen => self.allow_half_open_probe(),
|
||||
CircuitState::Open => AllowResult {
|
||||
allowed: false,
|
||||
used_half_open_permit: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
AllowResult {
|
||||
allowed: false,
|
||||
used_half_open_permit: false,
|
||||
}
|
||||
}
|
||||
CircuitState::HalfOpen => self.allow_half_open_probe(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录成功
|
||||
pub fn record_success(&self, used_half_open_permit: bool) {
|
||||
let state = *self.state.read();
|
||||
let config = self.config.read();
|
||||
|
||||
if used_half_open_permit {
|
||||
self.release_half_open_permit();
|
||||
}
|
||||
|
||||
// 重置失败计数
|
||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||
self.total_requests.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
if state == CircuitState::HalfOpen {
|
||||
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
if successes >= config.success_threshold {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_closed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录失败
|
||||
pub fn record_failure(&self, used_half_open_permit: bool) {
|
||||
let state = *self.state.read();
|
||||
let config = self.config.read();
|
||||
|
||||
if used_half_open_permit {
|
||||
self.release_half_open_permit();
|
||||
}
|
||||
|
||||
// 更新计数器
|
||||
let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.total_requests.fetch_add(1, Ordering::SeqCst);
|
||||
self.failed_requests.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
// 重置成功计数
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
|
||||
// 检查是否应该打开熔断器
|
||||
match state {
|
||||
CircuitState::HalfOpen => {
|
||||
// HalfOpen 状态下失败,立即转为 Open
|
||||
drop(config);
|
||||
self.transition_to_open();
|
||||
}
|
||||
CircuitState::Closed => {
|
||||
// 检查连续失败次数
|
||||
if failures >= config.failure_threshold {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_open();
|
||||
} else {
|
||||
// 检查错误率
|
||||
let total = self.total_requests.load(Ordering::SeqCst);
|
||||
let failed = self.failed_requests.load(Ordering::SeqCst);
|
||||
|
||||
if total >= config.min_requests {
|
||||
let error_rate = failed as f64 / total as f64;
|
||||
|
||||
if error_rate >= config.error_rate_threshold {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_open();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前状态
|
||||
#[allow(dead_code)]
|
||||
pub fn get_state(&self) -> CircuitState {
|
||||
*self.state.read()
|
||||
}
|
||||
|
||||
/// 获取统计信息
|
||||
#[allow(dead_code)]
|
||||
pub fn get_stats(&self) -> CircuitBreakerStats {
|
||||
CircuitBreakerStats {
|
||||
state: *self.state.read(),
|
||||
consecutive_failures: self.consecutive_failures.load(Ordering::SeqCst),
|
||||
consecutive_successes: self.consecutive_successes.load(Ordering::SeqCst),
|
||||
total_requests: self.total_requests.load(Ordering::SeqCst),
|
||||
failed_requests: self.failed_requests.load(Ordering::SeqCst),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cooldown_seconds(&self) -> u64 {
|
||||
if *self.state.read() != CircuitState::Open {
|
||||
return 0;
|
||||
}
|
||||
let config = self.config.read();
|
||||
self.last_opened_at.read().as_ref().map_or(0, |opened| {
|
||||
config
|
||||
.timeout_seconds
|
||||
.saturating_sub(opened.elapsed().as_secs())
|
||||
})
|
||||
}
|
||||
|
||||
/// 重置熔断器(手动恢复)
|
||||
#[allow(dead_code)]
|
||||
pub fn reset(&self) {
|
||||
self.transition_to_closed();
|
||||
}
|
||||
|
||||
fn allow_half_open_probe(&self) -> AllowResult {
|
||||
// 半开状态限流:只允许有限请求通过进行探测
|
||||
let max_half_open_requests = 1u32;
|
||||
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
if current < max_half_open_requests {
|
||||
AllowResult {
|
||||
allowed: true,
|
||||
used_half_open_permit: true,
|
||||
}
|
||||
} else {
|
||||
// 超过限额,回退计数,拒绝请求
|
||||
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
|
||||
AllowResult {
|
||||
allowed: false,
|
||||
used_half_open_permit: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅释放 HalfOpen permit,不影响健康统计
|
||||
///
|
||||
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
|
||||
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
|
||||
pub fn release_half_open_permit(&self) {
|
||||
let mut current = self.half_open_requests.load(Ordering::SeqCst);
|
||||
loop {
|
||||
if current == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
match self.half_open_requests.compare_exchange(
|
||||
current,
|
||||
current - 1,
|
||||
Ordering::SeqCst,
|
||||
Ordering::SeqCst,
|
||||
) {
|
||||
Ok(_) => return,
|
||||
Err(actual) => current = actual,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换到打开状态
|
||||
fn transition_to_open(&self) {
|
||||
*self.state.write() = CircuitState::Open;
|
||||
*self.last_opened_at.write() = Some(Instant::now());
|
||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 转换到半开状态
|
||||
fn transition_to_half_open(&self) {
|
||||
let mut state = self.state.write();
|
||||
if *state != CircuitState::Open {
|
||||
return;
|
||||
}
|
||||
|
||||
*state = CircuitState::HalfOpen;
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
// 重置半开状态的请求限流计数
|
||||
self.half_open_requests.store(0, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 转换到关闭状态
|
||||
fn transition_to_closed(&self) {
|
||||
*self.state.write() = CircuitState::Closed;
|
||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
// 重置计数器
|
||||
self.total_requests.store(0, Ordering::SeqCst);
|
||||
self.failed_requests.store(0, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// 熔断器统计信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CircuitBreakerStats {
|
||||
pub state: CircuitState,
|
||||
pub consecutive_failures: u32,
|
||||
pub consecutive_successes: u32,
|
||||
pub total_requests: u32,
|
||||
pub failed_requests: u32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_circuit_breaker_closed_to_open() {
|
||||
let config = CircuitBreakerConfig {
|
||||
failure_threshold: 3,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config);
|
||||
|
||||
// 初始状态应该是关闭
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
assert!(breaker.allow_request().allowed);
|
||||
|
||||
// 记录 3 次失败
|
||||
for _ in 0..3 {
|
||||
breaker.record_failure(false);
|
||||
}
|
||||
|
||||
// 应该转换到打开状态
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
assert!(!breaker.allow_request().allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circuit_breaker_half_open_to_closed() {
|
||||
let config = CircuitBreakerConfig {
|
||||
failure_threshold: 2,
|
||||
success_threshold: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config);
|
||||
|
||||
// 打开熔断器
|
||||
breaker.record_failure(false);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
|
||||
// 手动转换到半开状态
|
||||
breaker.transition_to_half_open();
|
||||
assert_eq!(breaker.get_state(), CircuitState::HalfOpen);
|
||||
|
||||
// 记录 2 次成功
|
||||
breaker.record_success(false);
|
||||
breaker.record_success(false);
|
||||
|
||||
// 应该转换到关闭状态
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_half_open_transition_does_not_reset_inflight_permit() {
|
||||
let config = CircuitBreakerConfig {
|
||||
timeout_seconds: 0,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config);
|
||||
|
||||
// 进入 Open,然后由于 timeout_seconds=0,allow_request 会立即切换到 HalfOpen 并占用探测名额
|
||||
breaker.transition_to_open();
|
||||
let first = breaker.allow_request();
|
||||
assert!(first.allowed);
|
||||
assert!(first.used_half_open_permit);
|
||||
assert_eq!(breaker.get_state(), CircuitState::HalfOpen);
|
||||
|
||||
// 模拟并发下的“重复 HalfOpen 转换调用”,不应重置 in-flight 计数
|
||||
breaker.transition_to_half_open();
|
||||
|
||||
// 由于名额仍被占用,第二次请求应被拒绝
|
||||
let second = breaker.allow_request();
|
||||
assert!(!second.allowed);
|
||||
assert!(!second.used_half_open_permit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circuit_breaker_reset() {
|
||||
let config = CircuitBreakerConfig {
|
||||
failure_threshold: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config);
|
||||
|
||||
// 打开熔断器
|
||||
breaker.record_failure(false);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
|
||||
// 重置
|
||||
breaker.reset();
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
assert!(breaker.allow_request().allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_rate_waits_for_minimum_requests_and_uses_inclusive_threshold() {
|
||||
let config = CircuitBreakerConfig {
|
||||
failure_threshold: 20,
|
||||
min_requests: 5,
|
||||
error_rate_threshold: 0.6,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config.clone());
|
||||
breaker.record_failure(false);
|
||||
breaker.record_success(false);
|
||||
breaker.record_failure(false);
|
||||
breaker.record_success(false);
|
||||
assert_eq!(breaker.get_stats().total_requests, 4);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
let higher = CircuitBreaker::new(CircuitBreakerConfig {
|
||||
error_rate_threshold: 0.8,
|
||||
..config.clone()
|
||||
});
|
||||
let minimum = CircuitBreaker::new(CircuitBreakerConfig {
|
||||
min_requests: 10,
|
||||
..config
|
||||
});
|
||||
for breaker in [&higher, &minimum] {
|
||||
breaker.record_failure(false);
|
||||
breaker.record_success(false);
|
||||
breaker.record_failure(false);
|
||||
breaker.record_success(false);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successes_break_consecutive_failures_without_clearing_error_rate_history() {
|
||||
let breaker = CircuitBreaker::new(CircuitBreakerConfig {
|
||||
failure_threshold: 2,
|
||||
min_requests: 100,
|
||||
..Default::default()
|
||||
});
|
||||
breaker.record_failure(false);
|
||||
breaker.record_success(false);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
assert_eq!(breaker.get_stats().failed_requests, 2);
|
||||
assert_eq!(breaker.get_stats().consecutive_failures, 1);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
assert_eq!(breaker.get_stats().total_requests, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_wait_and_hot_configuration_preserve_existing_evidence() {
|
||||
let mut config = CircuitBreakerConfig {
|
||||
failure_threshold: 1,
|
||||
timeout_seconds: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config.clone());
|
||||
breaker.record_failure(false);
|
||||
*breaker.last_opened_at.write() = Some(Instant::now() - std::time::Duration::from_secs(1));
|
||||
assert!(!breaker.allow_request().allowed);
|
||||
assert_eq!(breaker.cooldown_seconds(), 1);
|
||||
config.timeout_seconds = 1;
|
||||
breaker.update_config(config);
|
||||
assert_eq!(breaker.get_stats().failed_requests, 1);
|
||||
let probe = breaker.allow_request();
|
||||
assert!(probe.allowed && probe.used_half_open_permit);
|
||||
assert_eq!(breaker.get_state(), CircuitState::HalfOpen);
|
||||
assert!(!breaker.allow_request().allowed);
|
||||
breaker.release_half_open_permit();
|
||||
let probe = breaker.allow_request();
|
||||
assert!(probe.allowed);
|
||||
breaker.record_failure(probe.used_half_open_permit);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
assert!(!breaker.allow_request().allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_open_requires_configured_successes_and_reset_clears_all_statistics() {
|
||||
let breaker = CircuitBreaker::new(CircuitBreakerConfig {
|
||||
failure_threshold: 1,
|
||||
success_threshold: 3,
|
||||
timeout_seconds: 0,
|
||||
..Default::default()
|
||||
});
|
||||
breaker.record_failure(false);
|
||||
for completed in 0..3 {
|
||||
let probe = breaker.allow_request();
|
||||
assert!(probe.allowed && probe.used_half_open_permit);
|
||||
assert!(!breaker.allow_request().allowed);
|
||||
breaker.record_success(probe.used_half_open_permit);
|
||||
assert_eq!(
|
||||
breaker.get_state(),
|
||||
if completed < 2 {
|
||||
CircuitState::HalfOpen
|
||||
} else {
|
||||
CircuitState::Closed
|
||||
}
|
||||
);
|
||||
}
|
||||
assert_eq!(breaker.get_stats().total_requests, 0);
|
||||
breaker.record_failure(false);
|
||||
breaker.reset();
|
||||
let stats = breaker.get_stats();
|
||||
assert_eq!(
|
||||
(
|
||||
stats.total_requests,
|
||||
stats.failed_requests,
|
||||
stats.consecutive_failures,
|
||||
stats.consecutive_successes
|
||||
),
|
||||
(0, 0, 0, 0)
|
||||
);
|
||||
assert!(breaker.allow_request().allowed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Codex defaults/ranges follow CC Switch 06082e1 AppProxyConfig and its
|
||||
//! AutoFailoverConfigPanel. Both IPC and the UI validate these settings.
|
||||
|
||||
use crate::error::{CodexxError, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
pub(crate) const DEFAULT_LISTEN_ADDRESS: &str = "127.0.0.1";
|
||||
pub(crate) const DEFAULT_LISTEN_PORT: u16 = 15721;
|
||||
pub(crate) const MAX_QUEUE: usize = 64;
|
||||
pub(crate) const ROUTE_TOKEN_HEADER: &str = "x-codex-x-route-token";
|
||||
pub(crate) const ROUTE_GENERATION_HEADER: &str = "x-codex-x-route-generation";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub(crate) struct RoutingTuning {
|
||||
pub(crate) max_retries: u32,
|
||||
pub(crate) streaming_first_byte_timeout: u64,
|
||||
pub(crate) streaming_idle_timeout: u64,
|
||||
pub(crate) non_streaming_timeout: u64,
|
||||
pub(crate) circuit_failure_threshold: u32,
|
||||
pub(crate) circuit_success_threshold: u32,
|
||||
pub(crate) circuit_timeout_seconds: u64,
|
||||
pub(crate) circuit_error_rate_threshold: f64,
|
||||
pub(crate) circuit_min_requests: u32,
|
||||
}
|
||||
|
||||
impl Default for RoutingTuning {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_retries: 3,
|
||||
streaming_first_byte_timeout: 60,
|
||||
streaming_idle_timeout: 120,
|
||||
non_streaming_timeout: 600,
|
||||
circuit_failure_threshold: 4,
|
||||
circuit_success_threshold: 2,
|
||||
circuit_timeout_seconds: 60,
|
||||
circuit_error_rate_threshold: 0.6,
|
||||
circuit_min_requests: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RoutingTuning {
|
||||
pub(crate) fn validate(&self) -> Result<()> {
|
||||
for (valid, message) in [
|
||||
(self.max_retries <= 10, "最大重试次数须为 0–10"),
|
||||
(
|
||||
(1..=120).contains(&self.streaming_first_byte_timeout),
|
||||
"流式首字节超时须为 1–120 秒",
|
||||
),
|
||||
(
|
||||
self.streaming_idle_timeout <= 600,
|
||||
"流式静默超时须为 0–600 秒,0 表示禁用",
|
||||
),
|
||||
(
|
||||
(60..=1200).contains(&self.non_streaming_timeout),
|
||||
"非流式超时须为 60–1200 秒",
|
||||
),
|
||||
(
|
||||
(1..=20).contains(&self.circuit_failure_threshold),
|
||||
"失败阈值须为 1–20",
|
||||
),
|
||||
(
|
||||
(1..=10).contains(&self.circuit_success_threshold),
|
||||
"恢复成功阈值须为 1–10",
|
||||
),
|
||||
(
|
||||
self.circuit_timeout_seconds <= 300,
|
||||
"恢复等待时间须为 0–300 秒",
|
||||
),
|
||||
(
|
||||
self.circuit_error_rate_threshold.is_finite()
|
||||
&& (0.0..=1.0).contains(&self.circuit_error_rate_threshold),
|
||||
"错误率阈值须为 0–100%",
|
||||
),
|
||||
(
|
||||
(5..=100).contains(&self.circuit_min_requests),
|
||||
"最小请求数须为 5–100",
|
||||
),
|
||||
] {
|
||||
if !valid {
|
||||
return Err(CodexxError::Config(message.into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn listen_ip(address: &str) -> Result<IpAddr> {
|
||||
let address = address.trim();
|
||||
if address.eq_ignore_ascii_case("localhost") {
|
||||
return Ok(IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||
}
|
||||
let address = address
|
||||
.strip_prefix('[')
|
||||
.and_then(|value| value.strip_suffix(']'))
|
||||
.unwrap_or(address);
|
||||
address
|
||||
.parse()
|
||||
.map_err(|_| CodexxError::Config("监听地址须为 IPv4、IPv6 或 localhost".into()))
|
||||
}
|
||||
|
||||
pub(crate) fn client_ip(address: IpAddr) -> IpAddr {
|
||||
match address {
|
||||
IpAddr::V4(ip) if ip.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
IpAddr::V6(ip) if ip.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
address => address,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn local_url(address: IpAddr, port: u16) -> String {
|
||||
match client_ip(address) {
|
||||
IpAddr::V4(ip) => format!("http://{ip}:{port}/v1"),
|
||||
IpAddr::V6(ip) => format!("http://[{ip}]:{port}/v1"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn codex_defaults_and_valid_boundaries_match_reference() {
|
||||
let defaults = RoutingTuning::default();
|
||||
defaults.validate().unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
defaults.max_retries,
|
||||
defaults.streaming_first_byte_timeout,
|
||||
defaults.streaming_idle_timeout,
|
||||
defaults.non_streaming_timeout
|
||||
),
|
||||
(3, 60, 120, 600)
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
defaults.circuit_failure_threshold,
|
||||
defaults.circuit_success_threshold,
|
||||
defaults.circuit_timeout_seconds,
|
||||
defaults.circuit_min_requests
|
||||
),
|
||||
(4, 2, 60, 10)
|
||||
);
|
||||
assert_eq!(defaults.circuit_error_rate_threshold, 0.6);
|
||||
RoutingTuning {
|
||||
max_retries: 0,
|
||||
streaming_first_byte_timeout: 1,
|
||||
streaming_idle_timeout: 0,
|
||||
non_streaming_timeout: 60,
|
||||
circuit_failure_threshold: 1,
|
||||
circuit_success_threshold: 1,
|
||||
circuit_timeout_seconds: 0,
|
||||
circuit_error_rate_threshold: 0.0,
|
||||
circuit_min_requests: 5,
|
||||
}
|
||||
.validate()
|
||||
.unwrap();
|
||||
RoutingTuning {
|
||||
max_retries: 10,
|
||||
streaming_first_byte_timeout: 120,
|
||||
streaming_idle_timeout: 600,
|
||||
non_streaming_timeout: 1200,
|
||||
circuit_failure_threshold: 20,
|
||||
circuit_success_threshold: 10,
|
||||
circuit_timeout_seconds: 300,
|
||||
circuit_error_rate_threshold: 1.0,
|
||||
circuit_min_requests: 100,
|
||||
}
|
||||
.validate()
|
||||
.unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn invalid_tuning_is_rejected_in_backend() {
|
||||
for invalid in [
|
||||
RoutingTuning {
|
||||
max_retries: 11,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
streaming_first_byte_timeout: 0,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
streaming_first_byte_timeout: 121,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
streaming_idle_timeout: 601,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
non_streaming_timeout: 59,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
non_streaming_timeout: 1201,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_failure_threshold: 0,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_failure_threshold: 21,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_success_threshold: 0,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_success_threshold: 11,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_timeout_seconds: 301,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_error_rate_threshold: f64::NAN,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_error_rate_threshold: 1.1,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_min_requests: 4,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_min_requests: 101,
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
assert!(invalid.validate().is_err(), "{invalid:?}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn listen_addresses_and_client_urls_support_ipv4_ipv6_and_wildcards() {
|
||||
assert_eq!(listen_ip("localhost").unwrap().to_string(), "127.0.0.1");
|
||||
assert_eq!(
|
||||
local_url(listen_ip("0.0.0.0").unwrap(), 15721),
|
||||
"http://127.0.0.1:15721/v1"
|
||||
);
|
||||
assert_eq!(
|
||||
local_url(listen_ip("::").unwrap(), 15721),
|
||||
"http://[::1]:15721/v1"
|
||||
);
|
||||
assert_eq!(
|
||||
local_url(listen_ip("[::1]").unwrap(), 15721),
|
||||
"http://[::1]:15721/v1"
|
||||
);
|
||||
for value in [
|
||||
"256.0.0.1",
|
||||
"host.example",
|
||||
"127.0.0.1:15721",
|
||||
"::1/path",
|
||||
"",
|
||||
] {
|
||||
assert!(listen_ip(value).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
mod circuit_breaker;
|
||||
pub(crate) mod config;
|
||||
mod controller;
|
||||
pub(crate) mod native_official;
|
||||
mod proxy;
|
||||
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
pub(crate) use controller::resume_after_failed_update;
|
||||
|
||||
pub(crate) use controller::{
|
||||
attach_app_handle, direct_document, get_status, initialize, recover_stale_route,
|
||||
refresh_saved_routes, reset_health, save_settings, shutdown_all, with_provider_change,
|
||||
FailoverSettings, FailoverStatus,
|
||||
};
|
||||
@@ -0,0 +1,577 @@
|
||||
//! Native Codex official routing. Codex remains the owner of its login and token
|
||||
//! refresh; this layer only verifies the selected live login and forwards it to
|
||||
//! its fixed official origin. No credential is loaded from an inactive profile.
|
||||
|
||||
use super::proxy::ProxyRoute;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::providers::document_is_official;
|
||||
use crate::providers::official_profiles::{list_official_profiles_inner, selected_profile_id};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
const CHATGPT_BASE: &str = "https://chatgpt.com/backend-api/codex";
|
||||
const OPENAI_API_BASE: &str = "https://api.openai.com/v1";
|
||||
const MAX_CONFIG_BYTES: usize = 2 * 1024 * 1024;
|
||||
const MAX_AUTH_BYTES: usize = 1024 * 1024;
|
||||
const MAX_CATALOG_BYTES: usize = 1024 * 1024;
|
||||
const MAX_TOKEN_BYTES: usize = 32 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct OfficialRouteSpec {
|
||||
pub(crate) codex_dir: PathBuf,
|
||||
pub(crate) profile_id: String,
|
||||
}
|
||||
|
||||
// Intentionally not Debug/Serialize: these values must never appear in status,
|
||||
// frontend events, or error logs.
|
||||
pub(crate) struct OfficialRequestAuth {
|
||||
pub(crate) authorization: String,
|
||||
pub(crate) account_id: Option<String>,
|
||||
pub(crate) base_url: String,
|
||||
}
|
||||
|
||||
struct LiveAuth {
|
||||
token: String,
|
||||
account_id: Option<String>,
|
||||
base_url: &'static str,
|
||||
}
|
||||
|
||||
fn error(message: &str) -> CodexxError {
|
||||
CodexxError::Config(message.to_owned())
|
||||
}
|
||||
|
||||
fn read_bounded(path: &Path, limit: usize, missing_allowed: bool) -> Result<Vec<u8>> {
|
||||
let file = match fs::File::open(path) {
|
||||
Ok(file) => file,
|
||||
Err(err) if missing_allowed && err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(Vec::new())
|
||||
}
|
||||
Err(_) => return Err(error("无法读取当前官方登录配置,请检查文件后重试")),
|
||||
};
|
||||
if !file
|
||||
.metadata()
|
||||
.is_ok_and(|metadata| metadata.is_file() && metadata.len() <= limit as u64)
|
||||
{
|
||||
return Err(error("当前官方登录配置无法读取,请检查文件大小和格式"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(limit as u64 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| error("读取当前官方登录配置失败,请重试"))?;
|
||||
if bytes.len() > limit {
|
||||
return Err(error("当前官方登录配置过大,请检查文件"));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn logical_document(dir: &Path) -> Result<(Vec<u8>, DocumentMut)> {
|
||||
let bytes = read_bounded(&crate::config_path(dir), MAX_CONFIG_BYTES, true)?;
|
||||
let text = std::str::from_utf8(&bytes)
|
||||
.map_err(|_| error("当前 Codex 配置格式不正确,请先修复配置"))?;
|
||||
let doc = text
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|_| error("当前 Codex 配置格式不正确,请先修复配置"))?;
|
||||
let doc = super::direct_document(dir, &doc)
|
||||
.map_err(|_| error("无法确认当前官方路由,请检查本地路由状态"))?;
|
||||
Ok((bytes, doc))
|
||||
}
|
||||
|
||||
fn checked_secret(value: Option<&Value>) -> Option<String> {
|
||||
let value = value?.as_str()?.trim();
|
||||
(!value.is_empty()
|
||||
&& value.len() <= MAX_TOKEN_BYTES
|
||||
&& value.bytes().all(|byte| byte.is_ascii_graphic())
|
||||
&& value != "PROXY_MANAGED")
|
||||
.then(|| value.to_owned())
|
||||
}
|
||||
|
||||
fn checked_account(value: Option<&str>) -> Result<Option<String>> {
|
||||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if value.len() > 512 || !value.bytes().all(|byte| byte.is_ascii_graphic()) {
|
||||
return Err(error("当前官方账号信息无效,请重新登录 Codex"));
|
||||
}
|
||||
Ok(Some(value.to_owned()))
|
||||
}
|
||||
|
||||
fn parse_live_auth(bytes: &[u8]) -> Result<LiveAuth> {
|
||||
let auth: Value = serde_json::from_slice(bytes)
|
||||
.map_err(|_| error("官方账号尚未登录或认证无效,请在 Codex 中重新登录"))?;
|
||||
if !auth.is_object()
|
||||
|| ["base_url", "baseUrl", "api_base", "endpoint"]
|
||||
.iter()
|
||||
.any(|key| auth.get(key).is_some())
|
||||
{
|
||||
return Err(error("当前官方认证格式无效,请在 Codex 中重新登录"));
|
||||
}
|
||||
let mode = auth.get("auth_mode").and_then(Value::as_str);
|
||||
let api_key = checked_secret(auth.get("OPENAI_API_KEY"));
|
||||
let tokens = auth.get("tokens");
|
||||
let has_tokens = tokens.is_some_and(|value| {
|
||||
!value.is_null() && value.as_object().is_none_or(|values| !values.is_empty())
|
||||
});
|
||||
if let Some(token) = api_key {
|
||||
if mode.is_some_and(|value| !value.eq_ignore_ascii_case("apikey")) || has_tokens {
|
||||
return Err(error("官方登录与 API Key 认证混用,请在 Codex 中重新登录"));
|
||||
}
|
||||
return Ok(LiveAuth {
|
||||
token,
|
||||
account_id: None,
|
||||
base_url: OPENAI_API_BASE,
|
||||
});
|
||||
}
|
||||
if auth.get("OPENAI_API_KEY").is_some_and(|value| {
|
||||
!value.is_null() && value.as_str().is_none_or(|text| !text.trim().is_empty())
|
||||
}) {
|
||||
return Err(error("当前 OpenAI API Key 无效,请重新设置"));
|
||||
}
|
||||
if mode.is_some_and(|value| {
|
||||
!value.eq_ignore_ascii_case("chatgpt") && !value.eq_ignore_ascii_case("chatgptAuthTokens")
|
||||
}) {
|
||||
return Err(error(
|
||||
"此登录方式暂不支持本地路由,请使用 ChatGPT 登录或 OpenAI API Key",
|
||||
));
|
||||
}
|
||||
let token = checked_secret(auth.pointer("/tokens/access_token"))
|
||||
.ok_or_else(|| error("官方账号尚未登录,请在 Codex 中完成 ChatGPT 登录"))?;
|
||||
let account_id = checked_account(auth.pointer("/tokens/account_id").and_then(Value::as_str))?;
|
||||
Ok(LiveAuth {
|
||||
token,
|
||||
account_id,
|
||||
base_url: CHATGPT_BASE,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_selected(spec: &OfficialRouteSpec) -> Result<(Vec<u8>, DocumentMut)> {
|
||||
let (bytes, doc) = logical_document(&spec.codex_dir)?;
|
||||
if !document_is_official(&doc)
|
||||
|| selected_profile_id(&spec.codex_dir)
|
||||
.map_err(|_| error("无法确认当前官方账号,请刷新后重试"))?
|
||||
!= spec.profile_id
|
||||
{
|
||||
return Err(error("当前官方账号已切换,请重启 Codex 或新建会话后重试"));
|
||||
}
|
||||
Ok((bytes, doc))
|
||||
}
|
||||
|
||||
pub(crate) fn route_for_current(dir: &Path) -> Result<Option<ProxyRoute>> {
|
||||
let (_, doc) = logical_document(dir)?;
|
||||
if !document_is_official(&doc) {
|
||||
return Ok(None);
|
||||
}
|
||||
let profile_id = selected_profile_id(dir)?;
|
||||
let name = list_official_profiles_inner(Some(dir.display().to_string()))?
|
||||
.into_iter()
|
||||
.find(|profile| profile.id == profile_id)
|
||||
.map(|profile| profile.provider_name)
|
||||
.unwrap_or_else(|| "OpenAI Official".to_owned());
|
||||
// A logged-out official route can still be taken over. Codex may complete
|
||||
// login or refresh after startup; every request re-reads and verifies it.
|
||||
let base_url = read_bounded(&crate::auth_path(dir), MAX_AUTH_BYTES, true)
|
||||
.ok()
|
||||
.and_then(|bytes| parse_live_auth(&bytes).ok())
|
||||
.map_or(CHATGPT_BASE, |auth| auth.base_url)
|
||||
.to_owned();
|
||||
let models = crate::string_value(&doc, "model")
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>();
|
||||
Ok(Some(ProxyRoute {
|
||||
id: format!("official:{profile_id}"),
|
||||
name,
|
||||
base_url,
|
||||
api_key: None,
|
||||
headers: Vec::new(),
|
||||
models,
|
||||
official: Some(OfficialRouteSpec {
|
||||
codex_dir: dir.to_path_buf(),
|
||||
profile_id,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn verify_request(
|
||||
spec: &OfficialRouteSpec,
|
||||
authorization: &str,
|
||||
account_id: Option<&str>,
|
||||
) -> Result<OfficialRequestAuth> {
|
||||
let (config_before, _) = ensure_selected(spec)?;
|
||||
if authorization.len() > MAX_TOKEN_BYTES + 32 || authorization.chars().any(char::is_control) {
|
||||
return Err(error("官方请求认证无效,请在 Codex 中重新登录"));
|
||||
}
|
||||
let parts: Vec<_> = authorization.split_whitespace().collect();
|
||||
if parts.len() != 2 || !parts[0].eq_ignore_ascii_case("Bearer") {
|
||||
return Err(error("缺少官方请求认证,请在 Codex 中完成登录"));
|
||||
}
|
||||
let auth_before = read_bounded(&crate::auth_path(&spec.codex_dir), MAX_AUTH_BYTES, true)?;
|
||||
let auth = parse_live_auth(&auth_before)?;
|
||||
if parts[1] != auth.token || checked_account(account_id)? != auth.account_id {
|
||||
return Err(error(
|
||||
"此会话没有加载当前官方账号,请重启 Codex 或新建会话后重试",
|
||||
));
|
||||
}
|
||||
// Avoid trusting a token across a simultaneous account switch/logout/refresh.
|
||||
// This reads files only; acquiring the live mutation lock here would deadlock
|
||||
// callers constructing routes while holding their own configuration guard.
|
||||
let (config_after, _) = ensure_selected(spec)?;
|
||||
let auth_after = read_bounded(&crate::auth_path(&spec.codex_dir), MAX_AUTH_BYTES, true)?;
|
||||
if config_before != config_after || auth_before != auth_after {
|
||||
return Err(error("官方登录正在更新,请稍后重试"));
|
||||
}
|
||||
Ok(OfficialRequestAuth {
|
||||
authorization: format!("Bearer {}", auth.token),
|
||||
account_id: auth.account_id,
|
||||
base_url: auth.base_url.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_link(metadata: &fs::Metadata) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
metadata.file_type().is_symlink() || metadata.file_attributes() & 0x400 != 0
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
metadata.file_type().is_symlink()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn native_models(spec: &OfficialRouteSpec) -> Result<Value> {
|
||||
let (_, doc) = ensure_selected(spec)?;
|
||||
let Some(pointer) = doc.get("model_catalog_json").and_then(|item| item.as_str()) else {
|
||||
return Ok(json!({"models":[]}));
|
||||
};
|
||||
let path = Path::new(pointer);
|
||||
if path
|
||||
.components()
|
||||
.any(|part| matches!(part, Component::ParentDir))
|
||||
{
|
||||
return Ok(json!({"models":[]}));
|
||||
}
|
||||
let path = if path.is_absolute() {
|
||||
path.to_owned()
|
||||
} else {
|
||||
spec.codex_dir.join(path)
|
||||
};
|
||||
let owned = spec.codex_dir.join(".codex-x").join("model-catalogs");
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
return Ok(json!({"models":[]}));
|
||||
};
|
||||
if !name
|
||||
.strip_suffix(".json")
|
||||
.is_some_and(|hash| hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||
{
|
||||
return Ok(json!({"models":[]}));
|
||||
}
|
||||
for directory in [spec.codex_dir.join(".codex-x"), owned.clone()] {
|
||||
if !fs::symlink_metadata(directory)
|
||||
.is_ok_and(|metadata| !is_link(&metadata) && metadata.is_dir())
|
||||
{
|
||||
return Ok(json!({"models":[]}));
|
||||
}
|
||||
}
|
||||
if !fs::symlink_metadata(&path).is_ok_and(|metadata| !is_link(&metadata) && metadata.is_file())
|
||||
|| !path
|
||||
.parent()
|
||||
.and_then(|parent| parent.canonicalize().ok())
|
||||
.zip(owned.canonicalize().ok())
|
||||
.is_some_and(|(parent, owned)| parent == owned)
|
||||
{
|
||||
return Ok(json!({"models":[]}));
|
||||
}
|
||||
let Ok(bytes) = read_bounded(&path, MAX_CATALOG_BYTES, false) else {
|
||||
return Ok(json!({"models":[]}));
|
||||
};
|
||||
let Ok(value) = serde_json::from_slice::<Value>(&bytes) else {
|
||||
return Ok(json!({"models":[]}));
|
||||
};
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"codex-x-provider-model-catalog-v2\0");
|
||||
digest.update(serde_json::to_vec(&value).map_err(|_| error("本地模型目录格式无效"))?);
|
||||
if value
|
||||
.pointer("/_codex_x_model_catalog/source")
|
||||
.and_then(Value::as_str)
|
||||
!= Some("codex-x")
|
||||
|| !value.get("models").is_some_and(Value::is_array)
|
||||
|| format!("{:x}.json", digest.finalize()) != name
|
||||
{
|
||||
return Ok(json!({"models":[]}));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::providers::official_profiles::{
|
||||
save_official_profile_inner, switch_official_profile_inner, OfficialProfileInput,
|
||||
DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
struct Fixture {
|
||||
dir: PathBuf,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
static COUNT: AtomicU64 = AtomicU64::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"codex-x-native-official-{}-{}",
|
||||
std::process::id(),
|
||||
COUNT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(crate::config_path(&dir), "model_provider = 'custom'\nmodel='official-model'\n[model_providers.custom]\nname='OpenAI'\nrequires_openai_auth=true\nsupports_websockets=true\nwire_api='responses'\n[mcp_servers.keep]\ncommand='fixture-mcp'\n").unwrap();
|
||||
Self { dir }
|
||||
}
|
||||
fn oauth(&self, access: &str, account: &str) {
|
||||
fs::write(crate::auth_path(&self.dir), serde_json::to_vec(&json!({"auth_mode":"chatgpt","tokens":{"access_token":access,"refresh_token":"unused-refresh-fixture","account_id":account}})).unwrap()).unwrap();
|
||||
}
|
||||
fn spec(&self) -> OfficialRouteSpec {
|
||||
OfficialRouteSpec {
|
||||
codex_dir: self.dir.clone(),
|
||||
profile_id: DEFAULT_OFFICIAL_PROFILE_ID.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_official_checks_exact_live_token_even_for_team_members_sharing_workspace() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.oauth("live-user-a-token", "shared-team-workspace");
|
||||
let auth_before = fs::read(crate::auth_path(&fixture.dir)).unwrap();
|
||||
let config_before = fs::read(crate::config_path(&fixture.dir)).unwrap();
|
||||
let spec = fixture.spec();
|
||||
let allowed = verify_request(
|
||||
&spec,
|
||||
"Bearer live-user-a-token",
|
||||
Some("shared-team-workspace"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(allowed.base_url, CHATGPT_BASE);
|
||||
assert_eq!(allowed.account_id.as_deref(), Some("shared-team-workspace"));
|
||||
for (authorization, account) in [
|
||||
("Bearer another-user-token", Some("shared-team-workspace")),
|
||||
("Bearer live-user-a-token", Some("other-workspace")),
|
||||
("Bearer live-user-a-token", None),
|
||||
("Bearer PROXY_MANAGED", Some("shared-team-workspace")),
|
||||
("Bearer\nlive-user-a-token", Some("shared-team-workspace")),
|
||||
("", Some("shared-team-workspace")),
|
||||
] {
|
||||
let failure = verify_request(&spec, authorization, account)
|
||||
.err()
|
||||
.expect("must reject stale or wrong account credentials")
|
||||
.to_string();
|
||||
assert!(!failure.contains("live-user-a-token"));
|
||||
assert!(!failure.contains("another-user-token"));
|
||||
assert!(!failure.contains("shared-team-workspace"));
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read(crate::auth_path(&fixture.dir)).unwrap(),
|
||||
auth_before
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(crate::config_path(&fixture.dir)).unwrap(),
|
||||
config_before
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_official_login_and_token_refresh_are_owned_by_codex_without_cached_credentials() {
|
||||
let fixture = Fixture::new();
|
||||
let route = route_for_current(&fixture.dir).unwrap().unwrap();
|
||||
assert!(route.api_key.is_none());
|
||||
assert!(route.headers.is_empty());
|
||||
let spec = route.official.unwrap();
|
||||
assert!(verify_request(&spec, "Bearer absent", None).is_err());
|
||||
fixture.oauth("first-token", "account");
|
||||
assert!(verify_request(&spec, "Bearer first-token", Some("account")).is_ok());
|
||||
fixture.oauth("refreshed-token", "account");
|
||||
assert!(verify_request(&spec, "Bearer first-token", Some("account")).is_err());
|
||||
assert!(verify_request(&spec, "Bearer refreshed-token", Some("account")).is_ok());
|
||||
let latest = fs::read(crate::auth_path(&fixture.dir)).unwrap();
|
||||
route_for_current(&fixture.dir).unwrap();
|
||||
assert_eq!(fs::read(crate::auth_path(&fixture.dir)).unwrap(), latest);
|
||||
fs::remove_file(crate::auth_path(&fixture.dir)).unwrap();
|
||||
assert!(verify_request(&spec, "Bearer refreshed-token", Some("account")).is_err());
|
||||
assert!(route_for_current(&fixture.dir).unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_official_does_not_reuse_inactive_profile_or_third_party_oauth() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.oauth("account-a-token", "workspace");
|
||||
let scope = Some(fixture.dir.display().to_string());
|
||||
let other = save_official_profile_inner(OfficialProfileInput {
|
||||
config_dir: scope.clone(), id: None, provider_name: "Account B".into(),
|
||||
model: Some("official-model".into()), config_text: Some(fs::read_to_string(crate::config_path(&fixture.dir)).unwrap()),
|
||||
auth_json: Some(json!({"auth_mode":"chatgpt","tokens":{"access_token":"account-b-token","account_id":"workspace"}}).to_string()),
|
||||
}).unwrap();
|
||||
let spec = fixture.spec();
|
||||
assert!(verify_request(&spec, "Bearer account-b-token", Some("workspace")).is_err());
|
||||
switch_official_profile_inner(scope, other.profile.id.clone()).unwrap();
|
||||
assert!(verify_request(&spec, "Bearer account-b-token", Some("workspace")).is_err());
|
||||
let current = route_for_current(&fixture.dir)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.official
|
||||
.unwrap();
|
||||
assert_eq!(current.profile_id, other.profile.id);
|
||||
assert!(verify_request(¤t, "Bearer account-b-token", Some("workspace")).is_ok());
|
||||
fs::write(crate::config_path(&fixture.dir), "model_provider='custom'\nmodel='third'\n[model_providers.custom]\nname='Third'\nbase_url='https://third.example.test/v1'\nrequires_openai_auth=false\n").unwrap();
|
||||
assert!(route_for_current(&fixture.dir).unwrap().is_none());
|
||||
assert!(verify_request(¤t, "Bearer account-b-token", Some("workspace")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_api_key_uses_only_openai_api_origin_and_never_accepts_oauth_or_mixed_auth() {
|
||||
let fixture = Fixture::new();
|
||||
fs::write(
|
||||
crate::auth_path(&fixture.dir),
|
||||
json!({"auth_mode":"apikey","OPENAI_API_KEY":"api-key-fixture","tokens":null})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let spec = fixture.spec();
|
||||
let verified = verify_request(&spec, "Bearer api-key-fixture", None).unwrap();
|
||||
assert_eq!(verified.base_url, OPENAI_API_BASE);
|
||||
assert!(verified.account_id.is_none());
|
||||
assert!(verify_request(&spec, "Bearer oauth-fixture", None).is_err());
|
||||
assert!(
|
||||
verify_request(&spec, "Bearer api-key-fixture", Some("old-oauth-account")).is_err()
|
||||
);
|
||||
fs::write(
|
||||
crate::auth_path(&fixture.dir),
|
||||
json!({"OPENAI_API_KEY":"api-key-fixture","tokens":{"access_token":"oauth-fixture"}})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(verify_request(&spec, "Bearer api-key-fixture", None).is_err());
|
||||
assert!(verify_request(&spec, "Bearer oauth-fixture", None).is_err());
|
||||
fs::write(crate::auth_path(&fixture.dir), "{ broken-secret-json").unwrap();
|
||||
let failure = verify_request(&spec, "Bearer api-key-fixture", None)
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(!failure.contains("broken-secret-json"));
|
||||
assert!(!failure.contains("api-key-fixture"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_models_serve_only_verified_owned_catalog_and_never_read_arbitrary_json() {
|
||||
let fixture = Fixture::new();
|
||||
let spec = fixture.spec();
|
||||
assert_eq!(native_models(&spec).unwrap(), json!({"models":[]}));
|
||||
let config_path = crate::config_path(&fixture.dir);
|
||||
let mut doc = fs::read_to_string(&config_path)
|
||||
.unwrap()
|
||||
.parse::<DocumentMut>()
|
||||
.unwrap();
|
||||
crate::providers::model_catalog::prepare_model_catalog(
|
||||
&fixture.dir,
|
||||
"native-test",
|
||||
&[crate::providers::model_catalog::ProviderModelMapping {
|
||||
model: "local-model".into(),
|
||||
display_name: "Local".into(),
|
||||
context_window: None,
|
||||
}],
|
||||
"local-model",
|
||||
&mut doc,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(&config_path, doc.to_string()).unwrap();
|
||||
let owned = PathBuf::from(doc["model_catalog_json"].as_str().unwrap());
|
||||
let before = fs::read(&config_path).unwrap();
|
||||
assert_eq!(
|
||||
native_models(&spec).unwrap()["models"][0]["slug"],
|
||||
"local-model"
|
||||
);
|
||||
assert_eq!(fs::read(&config_path).unwrap(), before);
|
||||
let outside = fixture.dir.join("unrelated-auth.json");
|
||||
fs::write(&outside, r#"{"models":["must-not-leak"]}"#).unwrap();
|
||||
doc["model_catalog_json"] = toml_edit::value(outside.display().to_string());
|
||||
fs::write(&config_path, doc.to_string()).unwrap();
|
||||
assert_eq!(native_models(&spec).unwrap(), json!({"models":[]}));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
fs::remove_file(&owned).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, &owned).unwrap();
|
||||
doc["model_catalog_json"] = toml_edit::value(owned.display().to_string());
|
||||
fs::write(&config_path, doc.to_string()).unwrap();
|
||||
assert_eq!(native_models(&spec).unwrap(), json!({"models":[]}));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn native_local_models_endpoint_accepts_owned_live_oauth_without_network_refresh_or_fallback() {
|
||||
use super::super::config::{RoutingTuning, ROUTE_TOKEN_HEADER};
|
||||
use super::super::proxy::{ProxyHandle, ProxyOptions};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::time::Duration;
|
||||
struct Server(ProxyHandle);
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
self.0.shutdown();
|
||||
}
|
||||
}
|
||||
let fixture = Fixture::new();
|
||||
fixture.oauth("local-native-access", "local-native-account");
|
||||
let route = route_for_current(&fixture.dir).unwrap().unwrap();
|
||||
let local_token = "native-local-route-token-fixture-000000000000";
|
||||
let server = Server(
|
||||
ProxyHandle::start(
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
0,
|
||||
local_token.into(),
|
||||
vec![route],
|
||||
ProxyOptions {
|
||||
auto_failover_enabled: true,
|
||||
tuning: RoutingTuning::default(),
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap();
|
||||
for access in ["local-native-access", "local-native-refreshed"] {
|
||||
fixture.oauth(access, "local-native-account");
|
||||
let auth_before = fs::read(crate::auth_path(&fixture.dir)).unwrap();
|
||||
let response = client
|
||||
.get(format!("http://127.0.0.1:{}/v1/models", server.0.port()))
|
||||
.header(ROUTE_TOKEN_HEADER, local_token)
|
||||
.header("authorization", format!("Bearer {access}"))
|
||||
.header("chatgpt-account-id", "local-native-account")
|
||||
.send()
|
||||
.unwrap();
|
||||
assert_eq!(response.status().as_u16(), 200);
|
||||
let body: Value = serde_json::from_str(&response.text().unwrap()).unwrap();
|
||||
assert_eq!(body, json!({"models":[]}));
|
||||
assert_eq!(
|
||||
fs::read(crate::auth_path(&fixture.dir)).unwrap(),
|
||||
auth_before
|
||||
);
|
||||
}
|
||||
let snapshot = server.0.snapshot();
|
||||
assert_eq!(snapshot.success_count, 2);
|
||||
assert_eq!(snapshot.failover_count, 0);
|
||||
assert_eq!(
|
||||
snapshot.last_provider_id.as_deref(),
|
||||
Some("official:openai-official")
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,824 @@
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
|
||||
struct Fixture {
|
||||
dir: PathBuf,
|
||||
providers: Vec<SavedProvider>,
|
||||
original: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let number = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
let tag = format!("failover-test-{}-{number}", std::process::id());
|
||||
let dir = std::env::temp_dir().join(&tag);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let dir = dir.canonicalize().unwrap();
|
||||
let mut providers = Vec::new();
|
||||
for index in 0..3 {
|
||||
let provider = SavedProvider {
|
||||
id: format!("{tag}-{index}"),
|
||||
provider_name: format!("Provider {index}"),
|
||||
base_url: format!("https://{tag}-{index}.example.test/v1"),
|
||||
model: "same-model".into(),
|
||||
api_key: Some(format!("fixture-provider-secret-{index}")),
|
||||
toml_config: None,
|
||||
wire_api: "responses".into(),
|
||||
requires_openai_auth: false,
|
||||
model_mappings: vec![],
|
||||
};
|
||||
providers.push(crate::providers::save_provider_inner(provider).unwrap());
|
||||
}
|
||||
let primary = &providers[0];
|
||||
let original = format!("# preserve user config\nmodel_provider = \"custom\"\nmodel = \"same-model\"\nmodel_reasoning_effort = \"high\"\n\n[model_providers.custom]\nname = \"Provider 0\"\nbase_url = {:?}\nwire_api = \"responses\"\nrequires_openai_auth = false\nsupports_websockets = true\nexperimental_bearer_token = {:?}\nrequest_max_retries = 4\n\n[model_providers.custom.http_headers]\nx-fixture = \"primary-header\"\n\n[mcp_servers.fixture]\ncommand = \"fixture-tool\"\n", primary.base_url, primary.api_key.as_deref().unwrap());
|
||||
fs::write(crate::config_path(&dir), &original).unwrap();
|
||||
fs::write(
|
||||
dir.join("auth.json"),
|
||||
"{\"OPENAI_API_KEY\":\"untouched-official-auth-fixture\"}",
|
||||
)
|
||||
.unwrap();
|
||||
crate::providers::remember_active_provider_on_connection(
|
||||
&crate::app_db::open().unwrap(),
|
||||
&dir,
|
||||
&primary.id,
|
||||
)
|
||||
.unwrap();
|
||||
Self {
|
||||
dir,
|
||||
providers,
|
||||
original,
|
||||
port: free_port(),
|
||||
}
|
||||
}
|
||||
fn scope(&self) -> Option<String> {
|
||||
Some(self.dir.display().to_string())
|
||||
}
|
||||
fn settings(&self) -> FailoverSettings {
|
||||
FailoverSettings {
|
||||
router_enabled: true,
|
||||
takeover_enabled: true,
|
||||
auto_failover_enabled: true,
|
||||
listen_port: self.port,
|
||||
provider_ids: vec![self.providers[0].id.clone(), self.providers[1].id.clone()],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn text(&self) -> String {
|
||||
fs::read_to_string(crate::config_path(&self.dir)).unwrap()
|
||||
}
|
||||
fn enable(&self) -> FailoverStatus {
|
||||
save_settings(self.scope(), self.settings()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
SHUTTING_DOWN.store(false, Ordering::Release);
|
||||
// If a test deliberately left invalid TOML, repair only its private fixture.
|
||||
if let Ok(record) = load_record(&self.dir) {
|
||||
if let Ok(mut runtimes) = lock_manager() {
|
||||
if let Some(runtime) = runtimes.remove(&self.dir) {
|
||||
runtime.proxy.shutdown();
|
||||
}
|
||||
}
|
||||
let _ = record;
|
||||
}
|
||||
if let Ok(conn) = crate::app_db::open() {
|
||||
let _ = conn.execute(
|
||||
"DELETE FROM provider_failover WHERE codex_dir = ?1",
|
||||
[normalized_path_scope(&self.dir)],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"DELETE FROM active_provider_selections WHERE codex_dir = ?1",
|
||||
[normalized_path_scope(&self.dir)],
|
||||
);
|
||||
for provider in &self.providers {
|
||||
let _ = conn.execute("DELETE FROM providers WHERE id = ?1", [&provider.id]);
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn free_port() -> u16 {
|
||||
std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port()
|
||||
}
|
||||
fn stop(fixture: &Fixture) {
|
||||
let mut settings = load_record(&fixture.dir).unwrap().settings;
|
||||
settings.router_enabled = false;
|
||||
save_settings(fixture.scope(), settings).unwrap();
|
||||
}
|
||||
fn parsed(fixture: &Fixture) -> DocumentMut {
|
||||
fixture.text().parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_takeover_and_auto_switches_are_independent() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let auth = fs::read(fixture.dir.join("auth.json")).unwrap();
|
||||
let mut settings = fixture.settings();
|
||||
settings.takeover_enabled = false;
|
||||
settings.auto_failover_enabled = false;
|
||||
let listening = save_settings(fixture.scope(), settings.clone()).unwrap();
|
||||
assert!(listening.running);
|
||||
assert!(!listening.takeover_active);
|
||||
assert!(!listening.auto_failover_active);
|
||||
assert_eq!(fixture.text(), fixture.original);
|
||||
settings.takeover_enabled = true;
|
||||
let single = save_settings(fixture.scope(), settings.clone()).unwrap();
|
||||
assert!(single.takeover_active);
|
||||
assert!(!single.auto_failover_active);
|
||||
assert_eq!(single.runtime.providers.len(), 1);
|
||||
assert_eq!(single.primary.as_ref().unwrap().id, fixture.providers[0].id);
|
||||
settings.auto_failover_enabled = true;
|
||||
let automatic = save_settings(fixture.scope(), settings).unwrap();
|
||||
assert!(automatic.auto_failover_active);
|
||||
assert_eq!(automatic.runtime.providers.len(), 2);
|
||||
assert_eq!(fs::read(fixture.dir.join("auth.json")).unwrap(), auth);
|
||||
stop(&fixture);
|
||||
let stopped = get_status(fixture.scope()).unwrap();
|
||||
assert!(!stopped.running && !stopped.takeover_active);
|
||||
assert!(stopped.settings.auto_failover_enabled);
|
||||
assert_eq!(stopped.settings.provider_ids.len(), 2);
|
||||
assert_eq!(
|
||||
parsed(&fixture)["mcp_servers"]["fixture"]["command"].as_str(),
|
||||
Some("fixture-tool")
|
||||
);
|
||||
assert_eq!(
|
||||
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some(fixture.providers[0].base_url.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabling_auto_switches_to_p1_even_if_current_is_elsewhere() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let mut settings = fixture.settings();
|
||||
settings.provider_ids = vec![
|
||||
fixture.providers[2].id.clone(),
|
||||
fixture.providers[0].id.clone(),
|
||||
];
|
||||
let status = save_settings(fixture.scope(), settings).unwrap();
|
||||
assert!(status.auto_failover_active);
|
||||
assert_eq!(status.primary.as_ref().unwrap().id, fixture.providers[2].id);
|
||||
assert_eq!(status.runtime.providers[0].id, fixture.providers[2].id);
|
||||
assert_eq!(status.settings.provider_ids[0], fixture.providers[2].id);
|
||||
stop(&fixture);
|
||||
assert_eq!(
|
||||
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some(fixture.providers[2].base_url.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_provider_queue_and_an_empty_running_queue_are_valid() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let mut settings = fixture.settings();
|
||||
settings.provider_ids.clear();
|
||||
let single = save_settings(fixture.scope(), settings).unwrap();
|
||||
assert_eq!(
|
||||
single.settings.provider_ids,
|
||||
vec![fixture.providers[0].id.clone()]
|
||||
);
|
||||
assert!(single.auto_failover_active);
|
||||
let mut settings = single.settings;
|
||||
settings.provider_ids.clear();
|
||||
let empty = save_settings(fixture.scope(), settings).unwrap();
|
||||
assert!(empty.running && empty.auto_failover_active);
|
||||
assert!(empty.runtime.providers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_accepts_different_models_and_can_be_prepared_while_stopped() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.providers[1].model = "another-model".into();
|
||||
crate::providers::save_provider_inner(fixture.providers[1].clone()).unwrap();
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(
|
||||
status
|
||||
.providers
|
||||
.iter()
|
||||
.find(|p| p.id == fixture.providers[1].id)
|
||||
.unwrap()
|
||||
.eligible
|
||||
);
|
||||
let mut settings = fixture.settings();
|
||||
settings.router_enabled = false;
|
||||
settings.takeover_enabled = false;
|
||||
settings.auto_failover_enabled = false;
|
||||
let saved = save_settings(fixture.scope(), settings).unwrap();
|
||||
assert!(!saved.running);
|
||||
assert_eq!(fixture.text(), fixture.original);
|
||||
assert_eq!(saved.settings.provider_ids.len(), 2);
|
||||
fixture.enable();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_and_scope_validation_have_no_live_side_effects() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let mut invalid = fixture.settings();
|
||||
invalid.listen_port = 1000;
|
||||
assert!(save_settings(fixture.scope(), invalid).is_err());
|
||||
let mut invalid = fixture.settings();
|
||||
invalid.provider_ids.push(invalid.provider_ids[0].clone());
|
||||
assert!(save_settings(fixture.scope(), invalid).is_err());
|
||||
let mut invalid = fixture.settings();
|
||||
invalid.provider_ids = vec!["official:openai-official".into()];
|
||||
assert!(save_settings(fixture.scope(), invalid).is_err());
|
||||
let mut invalid = fixture.settings();
|
||||
invalid.takeover_enabled = false;
|
||||
assert!(save_settings(fixture.scope(), invalid).is_err());
|
||||
assert_eq!(fixture.text(), fixture.original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_bind_and_missing_p1_leave_configuration_and_settings_unchanged() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let blocker =
|
||||
std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, fixture.port)).unwrap();
|
||||
assert!(save_settings(fixture.scope(), fixture.settings()).is_err());
|
||||
assert_eq!(fixture.text(), fixture.original);
|
||||
assert!(!get_status(fixture.scope()).unwrap().running);
|
||||
drop(blocker);
|
||||
let mut settings = fixture.settings();
|
||||
settings.provider_ids = vec!["missing-p1".into()];
|
||||
assert!(save_settings(fixture.scope(), settings).is_err());
|
||||
assert_eq!(fixture.text(), fixture.original);
|
||||
assert!(!get_status(fixture.scope()).unwrap().settings.router_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_and_tuning_updates_keep_the_endpoint_and_update_all_parameters() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let before = fixture.text();
|
||||
let mut settings = fixture.settings();
|
||||
settings.provider_ids.reverse();
|
||||
settings.tuning = RoutingTuning {
|
||||
max_retries: 8,
|
||||
streaming_first_byte_timeout: 10,
|
||||
streaming_idle_timeout: 0,
|
||||
non_streaming_timeout: 900,
|
||||
circuit_failure_threshold: 7,
|
||||
circuit_success_threshold: 4,
|
||||
circuit_timeout_seconds: 45,
|
||||
circuit_error_rate_threshold: 0.75,
|
||||
circuit_min_requests: 15,
|
||||
};
|
||||
let status = save_settings(fixture.scope(), settings.clone()).unwrap();
|
||||
assert_eq!(status.settings, settings);
|
||||
assert_eq!(fixture.text(), before);
|
||||
assert_eq!(status.runtime.providers[0].id, fixture.providers[1].id);
|
||||
let mut bad = status.settings;
|
||||
bad.listen_port = free_port();
|
||||
assert!(save_settings(fixture.scope(), bad).is_err());
|
||||
assert_eq!(fixture.text(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_switch_keeps_routing_and_auto_but_official_never_joins_api_queue() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
with_provider_change(fixture.scope(), || {
|
||||
crate::providers::activate_saved_provider_inner(
|
||||
fixture.scope(),
|
||||
fixture.providers[2].id.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let switched = get_status(fixture.scope()).unwrap();
|
||||
assert!(switched.running && switched.auto_failover_active);
|
||||
assert_eq!(switched.primary.unwrap().id, fixture.providers[2].id);
|
||||
with_provider_change(fixture.scope(), || {
|
||||
crate::providers::official_profiles::switch_official_profile_inner(
|
||||
fixture.scope(),
|
||||
crate::providers::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID.into(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let official = get_status(fixture.scope()).unwrap();
|
||||
assert!(official.running && official.takeover_active);
|
||||
assert!(!official.auto_failover_active);
|
||||
assert!(official.settings.auto_failover_enabled);
|
||||
assert!(official.primary.unwrap().official);
|
||||
assert!(official
|
||||
.runtime
|
||||
.providers
|
||||
.iter()
|
||||
.all(|p| p.id.starts_with("official:")));
|
||||
assert!(official.providers.iter().all(|p| !p.official));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_takeover_preserves_auth_and_can_restore_a_builtin_route() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let original = "model='official-model'\n[mcp_servers.test]\ncommand='tool'\n";
|
||||
fs::write(crate::config_path(&fixture.dir), original).unwrap();
|
||||
let auth = fs::read(fixture.dir.join("auth.json")).unwrap();
|
||||
let mut settings = fixture.settings();
|
||||
settings.auto_failover_enabled = false;
|
||||
settings.provider_ids.clear();
|
||||
let status = save_settings(fixture.scope(), settings).unwrap();
|
||||
assert!(status.primary.unwrap().official);
|
||||
assert!(status.takeover_active && !status.auto_failover_active);
|
||||
let doc = parsed(&fixture);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["openai"]["requires_openai_auth"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
assert!(doc["model_providers"]["openai"]
|
||||
.get("experimental_bearer_token")
|
||||
.is_none());
|
||||
assert!(doc["model_providers"]["openai"]["http_headers"]
|
||||
.get(ROUTE_TOKEN_HEADER)
|
||||
.is_some());
|
||||
assert_eq!(fs::read(fixture.dir.join("auth.json")).unwrap(), auth);
|
||||
stop(&fixture);
|
||||
assert!(parsed(&fixture).get("model_providers").is_none());
|
||||
assert_eq!(
|
||||
parsed(&fixture)["mcp_servers"]["test"]["command"].as_str(),
|
||||
Some("tool")
|
||||
);
|
||||
assert_eq!(fs::read(fixture.dir.join("auth.json")).unwrap(), auth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_manual_switch_reattaches_original_route() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let error = with_provider_change::<()>(fixture.scope(), || {
|
||||
Err(CodexxError::Config("expected switch failure".into()))
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("expected switch failure"));
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(status.running && status.takeover_active);
|
||||
assert_eq!(status.primary.unwrap().id, fixture.providers[0].id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_keeps_external_edits_and_does_not_leave_local_headers() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let mut doc = parsed(&fixture);
|
||||
doc["model_reasoning_effort"] = value("max");
|
||||
doc["model_providers"]["custom"]["request_max_retries"] = value(9);
|
||||
doc["model_providers"]["custom"]["external_new_setting"] = value("preserved");
|
||||
fs::write(crate::config_path(&fixture.dir), doc.to_string()).unwrap();
|
||||
stop(&fixture);
|
||||
let restored = parsed(&fixture);
|
||||
assert_eq!(restored["model_reasoning_effort"].as_str(), Some("max"));
|
||||
assert_eq!(
|
||||
restored["model_providers"]["custom"]["request_max_retries"].as_integer(),
|
||||
Some(9)
|
||||
);
|
||||
assert_eq!(
|
||||
restored["model_providers"]["custom"]["external_new_setting"].as_str(),
|
||||
Some("preserved")
|
||||
);
|
||||
assert_eq!(
|
||||
restored["model_providers"]["custom"]["http_headers"]["x-fixture"].as_str(),
|
||||
Some("primary-header")
|
||||
);
|
||||
assert!(!fixture.text().contains(ROUTE_GENERATION_HEADER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_provider_change_detaches_without_stopping_the_listener() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let external = fixture
|
||||
.original
|
||||
.replace(
|
||||
&fixture.providers[0].base_url,
|
||||
&fixture.providers[2].base_url,
|
||||
)
|
||||
.replace("fixture-provider-secret-0", "fixture-provider-secret-2");
|
||||
fs::write(crate::config_path(&fixture.dir), &external).unwrap();
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(status.running && !status.takeover_active);
|
||||
assert!(!status.settings.takeover_enabled);
|
||||
assert_eq!(fixture.text(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_restore_keeps_the_listener_alive() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let managed = fixture.text();
|
||||
fs::write(crate::config_path(&fixture.dir), "invalid = [").unwrap();
|
||||
let mut off = fixture.settings();
|
||||
off.router_enabled = false;
|
||||
assert!(save_settings(fixture.scope(), off).is_err());
|
||||
assert!(lock_manager().unwrap().contains_key(&fixture.dir));
|
||||
fs::write(crate::config_path(&fixture.dir), managed).unwrap();
|
||||
stop(&fixture);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_update_handoff_resumes_saved_routes_and_reopens_mutation_gate() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let before = fixture.enable();
|
||||
let auth = fs::read(crate::auth_path(&fixture.dir)).unwrap();
|
||||
shutdown_all().unwrap();
|
||||
assert!(!get_status(fixture.scope()).unwrap().running);
|
||||
assert!(!fixture.text().contains("http://127.0.0.1:"));
|
||||
resume_after_failed_update().unwrap();
|
||||
let after = get_status(fixture.scope()).unwrap();
|
||||
assert!(after.running && after.takeover_active);
|
||||
assert_eq!(after.settings, before.settings);
|
||||
assert_eq!(fs::read(crate::auth_path(&fixture.dir)).unwrap(), auth);
|
||||
stop(&fixture);
|
||||
assert!(!get_status(fixture.scope()).unwrap().running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_update_recovery_reports_busy_port_without_erasing_saved_preferences() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let before = fixture.enable();
|
||||
shutdown_all().unwrap();
|
||||
let occupied =
|
||||
std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, fixture.port)).unwrap();
|
||||
assert!(resume_after_failed_update().is_err());
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(!status.running);
|
||||
assert_eq!(status.settings, before.settings);
|
||||
assert!(!fixture.text().contains("http://127.0.0.1:"));
|
||||
drop(occupied);
|
||||
resume_after_failed_update().unwrap();
|
||||
assert!(get_status(fixture.scope()).unwrap().takeover_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_directory_scopes_resolve_to_the_interactive_runtime_key() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let scope = normalized_path_scope(&fixture.dir);
|
||||
let stored = stored_directories().unwrap();
|
||||
let restored = stored
|
||||
.iter()
|
||||
.find(|dir| normalized_path_scope(dir) == scope)
|
||||
.unwrap();
|
||||
assert_eq!(*restored, directory(fixture.scope()).unwrap());
|
||||
assert!(lock_manager().unwrap().contains_key(restored));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_restores_and_rejects_queued_enabling_then_restart_resumes_same_port() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
shutdown_all().unwrap();
|
||||
assert!(!fixture.text().contains("http://127.0.0.1:"));
|
||||
let record = load_record(&fixture.dir).unwrap();
|
||||
assert!(record.settings.router_enabled && record.settings.takeover_enabled);
|
||||
assert!(save_settings(fixture.scope(), fixture.settings()).is_err());
|
||||
SHUTTING_DOWN.store(false, Ordering::Release);
|
||||
initialize().unwrap();
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(status.takeover_active);
|
||||
assert_eq!(status.settings.listen_port, fixture.port);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_managed_backups_keep_their_original_provider_after_later_takeovers() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let old = fixture.text();
|
||||
with_provider_change(fixture.scope(), || {
|
||||
crate::providers::activate_saved_provider_inner(
|
||||
fixture.scope(),
|
||||
fixture.providers[2].id.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
stop(&fixture);
|
||||
fs::write(crate::config_path(&fixture.dir), old).unwrap();
|
||||
recover_stale_route(fixture.scope()).unwrap();
|
||||
assert_eq!(
|
||||
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some(fixture.providers[0].base_url.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_enabled_backups_settings_migrate_to_full_p1_queue() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let old = serde_json::json!({"settings":{"enabled":true,"providerIds":[fixture.providers[1].id]},"journals":[{"primaryId":fixture.providers[0].id,"providerKey":"custom","originalTable":"name='old'","port":fixture.port,"token":"old-only-test-token"}]});
|
||||
crate::app_db::open()
|
||||
.unwrap()
|
||||
.execute(
|
||||
"INSERT INTO provider_failover(codex_dir,record_json) VALUES(?1,?2)",
|
||||
params![normalized_path_scope(&fixture.dir), old.to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
let migrated = load_record(&fixture.dir).unwrap();
|
||||
assert_eq!(migrated.version, 2);
|
||||
assert!(
|
||||
migrated.settings.router_enabled
|
||||
&& migrated.settings.takeover_enabled
|
||||
&& migrated.settings.auto_failover_enabled
|
||||
);
|
||||
assert_eq!(
|
||||
migrated.settings.provider_ids,
|
||||
vec![
|
||||
fixture.providers[0].id.clone(),
|
||||
fixture.providers[1].id.clone()
|
||||
]
|
||||
);
|
||||
assert_eq!(migrated.settings.tuning, RoutingTuning::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_never_serializes_provider_credentials_or_local_tokens() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let status = fixture.enable();
|
||||
let text = serde_json::to_string(&status).unwrap();
|
||||
assert!(!text.contains("fixture-provider-secret"));
|
||||
let record = load_record(&fixture.dir).unwrap();
|
||||
assert!(!text.contains(&record.journals.last().unwrap().token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_one_queue_member_keeps_the_rest_and_listener() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
crate::providers::delete_provider_inner(&fixture.providers[1].id).unwrap();
|
||||
refresh_saved_routes().unwrap();
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(status.running && status.takeover_active);
|
||||
assert_eq!(status.runtime.providers.len(), 1);
|
||||
assert_eq!(status.runtime.providers[0].id, fixture.providers[0].id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_can_run_without_a_configured_provider() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fs::remove_file(crate::config_path(&fixture.dir)).unwrap();
|
||||
let status = save_settings(
|
||||
fixture.scope(),
|
||||
FailoverSettings {
|
||||
router_enabled: true,
|
||||
listen_port: fixture.port,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(status.running && !status.takeover_active);
|
||||
assert!(!crate::config_path(&fixture.dir).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_fallback_updates_logical_provider_and_preserves_old_backup_identity() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.providers[1].model = "different-default".into();
|
||||
crate::providers::save_provider_inner(fixture.providers[1].clone()).unwrap();
|
||||
fixture.enable();
|
||||
let earlier_backup = fixture.text();
|
||||
let mut runtimes = lock_manager().unwrap();
|
||||
let runtime = runtimes.get(&fixture.dir).unwrap();
|
||||
let notice = SelectionNotice {
|
||||
dir: fixture.dir.clone(),
|
||||
instance_id: runtime.instance_id,
|
||||
token: runtime.token.clone(),
|
||||
event: ProxySelectionEvent {
|
||||
provider_id: fixture.providers[1].id.clone(),
|
||||
revision: runtime.proxy.revision(),
|
||||
},
|
||||
};
|
||||
record_selection(notice, &mut runtimes).unwrap();
|
||||
drop(runtimes);
|
||||
let selected = get_status(fixture.scope()).unwrap();
|
||||
assert_eq!(selected.primary.unwrap().id, fixture.providers[1].id);
|
||||
assert_eq!(parsed(&fixture)["model"].as_str(), Some("same-model"));
|
||||
let direct = direct_document(&fixture.dir, &parsed(&fixture)).unwrap();
|
||||
assert_eq!(
|
||||
direct["model_providers"]["custom"]["name"].as_str(),
|
||||
Some("Provider 1")
|
||||
);
|
||||
assert_eq!(
|
||||
direct["model_providers"]["custom"]["experimental_bearer_token"].as_str(),
|
||||
Some("fixture-provider-secret-1")
|
||||
);
|
||||
stop(&fixture);
|
||||
assert_eq!(
|
||||
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some(fixture.providers[1].base_url.as_str())
|
||||
);
|
||||
fs::write(crate::config_path(&fixture.dir), earlier_backup).unwrap();
|
||||
recover_stale_route(fixture.scope()).unwrap();
|
||||
assert_eq!(
|
||||
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some(fixture.providers[0].base_url.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_success_cannot_override_a_manual_switch_or_a_changed_queue() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let notice = {
|
||||
let runtimes = lock_manager().unwrap();
|
||||
let runtime = runtimes.get(&fixture.dir).unwrap();
|
||||
SelectionNotice {
|
||||
dir: fixture.dir.clone(),
|
||||
instance_id: runtime.instance_id,
|
||||
token: runtime.token.clone(),
|
||||
event: ProxySelectionEvent {
|
||||
provider_id: fixture.providers[1].id.clone(),
|
||||
revision: runtime.proxy.revision(),
|
||||
},
|
||||
}
|
||||
};
|
||||
with_provider_change(fixture.scope(), || {
|
||||
crate::providers::activate_saved_provider_inner(
|
||||
fixture.scope(),
|
||||
fixture.providers[2].id.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
record_selection(notice, &mut lock_manager().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
get_status(fixture.scope()).unwrap().primary.unwrap().id,
|
||||
fixture.providers[2].id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_success_from_a_stopped_listener_cannot_change_the_restarted_route() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let notice = {
|
||||
let runtimes = lock_manager().unwrap();
|
||||
let runtime = runtimes.get(&fixture.dir).unwrap();
|
||||
SelectionNotice {
|
||||
dir: fixture.dir.clone(),
|
||||
instance_id: runtime.instance_id,
|
||||
token: runtime.token.clone(),
|
||||
event: ProxySelectionEvent {
|
||||
provider_id: fixture.providers[1].id.clone(),
|
||||
revision: runtime.proxy.revision(),
|
||||
},
|
||||
}
|
||||
};
|
||||
stop(&fixture);
|
||||
fixture.enable();
|
||||
let mut runtimes = lock_manager().unwrap();
|
||||
let restarted = runtimes.get(&fixture.dir).unwrap();
|
||||
assert_eq!(notice.token, restarted.token);
|
||||
assert_eq!(notice.event.revision, restarted.proxy.revision());
|
||||
assert_ne!(notice.instance_id, restarted.instance_id);
|
||||
record_selection(notice, &mut runtimes).unwrap();
|
||||
drop(runtimes);
|
||||
assert_eq!(
|
||||
get_status(fixture.scope()).unwrap().primary.unwrap().id,
|
||||
fixture.providers[0].id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_journal_write_failure_after_p1_rolls_back_files_and_common_recovery_marker() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let before = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
let target = &fixture.providers[2].id;
|
||||
crate::app_db::open().unwrap().execute_batch(&format!("CREATE TRIGGER reject_p1_journal BEFORE UPDATE ON provider_failover WHEN json_extract(NEW.record_json,'$.journals[#-1].primaryId') = '{target}' BEGIN SELECT RAISE(ABORT,'fixture-journal-failure'); END;")).unwrap();
|
||||
let mut settings = fixture.settings();
|
||||
settings.provider_ids = vec![target.clone()];
|
||||
let result = save_settings(fixture.scope(), settings);
|
||||
crate::app_db::open()
|
||||
.unwrap()
|
||||
.execute_batch("DROP TRIGGER reject_p1_journal")
|
||||
.unwrap();
|
||||
assert!(result.is_err());
|
||||
let after = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
assert_eq!(before.config, after.config);
|
||||
assert_eq!(before.auth, after.auth);
|
||||
assert_eq!(before.selected, after.selected);
|
||||
assert_eq!(before.common_handled, after.common_handled);
|
||||
assert!(!get_status(fixture.scope()).unwrap().running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p1_journal_failure_restores_official_endpoint_before_oauth() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let mut fixture = Fixture::new();
|
||||
fs::write(crate::config_path(&fixture.dir), "model='official-model'\n").unwrap();
|
||||
fs::write(crate::auth_path(&fixture.dir),r#"{"auth_mode":"chatgpt","tokens":{"access_token":"fixture-oauth","account_id":"fixture-account"}}"#).unwrap();
|
||||
fixture.providers[2].requires_openai_auth = true;
|
||||
fixture.providers[2] =
|
||||
crate::providers::save_provider_inner(fixture.providers[2].clone()).unwrap();
|
||||
let before = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
let mut settings = fixture.settings();
|
||||
settings.provider_ids = vec![fixture.providers[2].id.clone()];
|
||||
// Verify the prepared direct route really reads the global auth file. This
|
||||
// is the interval a failed journal write must undo before publishing OAuth.
|
||||
switch_to_p1(&fixture.dir, &mut settings).unwrap();
|
||||
let prepared = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
let doc = parsed(&fixture);
|
||||
let key = doc["model_provider"].as_str().unwrap();
|
||||
assert_eq!(
|
||||
doc["model_providers"][key]["requires_openai_auth"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
assert!(doc["model_providers"][key]
|
||||
.get("experimental_bearer_token")
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
crate::providers::replacement_write_order(
|
||||
prepared.config.as_deref(),
|
||||
before.config.as_deref()
|
||||
),
|
||||
crate::providers::LiveWriteOrder::ConfigFirst
|
||||
);
|
||||
before.restore(&fixture.dir, &prepared).unwrap();
|
||||
let target = &fixture.providers[2].id;
|
||||
crate::app_db::open().unwrap().execute_batch(&format!("CREATE TRIGGER reject_official_p1 BEFORE UPDATE ON provider_failover WHEN json_extract(NEW.record_json,'$.journals[#-1].primaryId') = '{target}' BEGIN SELECT RAISE(ABORT,'fixture-journal-failure'); END;")).unwrap();
|
||||
let result = save_settings(fixture.scope(), settings);
|
||||
crate::app_db::open()
|
||||
.unwrap()
|
||||
.execute_batch("DROP TRIGGER reject_official_p1")
|
||||
.unwrap();
|
||||
assert!(result
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("fixture-journal-failure"));
|
||||
let after = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
assert_eq!(before.config, after.config);
|
||||
assert_eq!(before.auth, after.auth);
|
||||
assert_eq!(before.selected, after.selected);
|
||||
assert_eq!(before.common_handled, after.common_handled);
|
||||
assert!(!get_status(fixture.scope()).unwrap().running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_persisted_settings_restore_direct_without_restarting_listener() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let runtime = lock_manager().unwrap().remove(&fixture.dir).unwrap();
|
||||
runtime.proxy.shutdown();
|
||||
let mut record = load_record(&fixture.dir).unwrap();
|
||||
record.settings.tuning.max_retries = 11;
|
||||
save_record(&fixture.dir, &record).unwrap();
|
||||
initialize().unwrap();
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(!status.running && !status.takeover_active);
|
||||
assert!(!fixture.text().contains("http://127.0.0.1:"));
|
||||
assert!(status.message.unwrap().contains("路由设置需要检查"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_status_write_failure_keeps_the_attached_listener_alive() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
shutdown_all().unwrap();
|
||||
let conn = crate::app_db::open().unwrap();
|
||||
conn.execute_batch("CREATE TRIGGER reject_redundant_status BEFORE UPDATE ON provider_failover WHEN NEW.record_json=OLD.record_json BEGIN SELECT RAISE(ABORT,'fixture-status-failure'); END;").unwrap();
|
||||
SHUTTING_DOWN.store(false, Ordering::Release);
|
||||
let result = initialize();
|
||||
conn.execute_batch("DROP TRIGGER reject_redundant_status")
|
||||
.unwrap();
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("fixture-status-failure"));
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(status.running && status.takeover_active);
|
||||
assert!(fixture.text().contains("http://127.0.0.1:"));
|
||||
assert!(std::net::TcpStream::connect((std::net::Ipv4Addr::LOCALHOST, fixture.port)).is_ok());
|
||||
stop(&fixture);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
use super::*;
|
||||
|
||||
struct OwnedRouteFixture {
|
||||
dir: PathBuf,
|
||||
raw: String,
|
||||
local_token: String,
|
||||
}
|
||||
|
||||
impl OwnedRouteFixture {
|
||||
fn new() -> Self {
|
||||
let unique = format!(
|
||||
"{}-{}",
|
||||
std::process::id(),
|
||||
Local::now().timestamp_nanos_opt().unwrap()
|
||||
);
|
||||
let dir = std::env::temp_dir().join(format!("codex-x-failover-integration-{unique}"));
|
||||
fs::create_dir_all(&dir).expect("create isolated Codex directory");
|
||||
let local_token = format!("local-token-{unique}");
|
||||
let original = r#"name = "Primary"
|
||||
base_url = "https://primary.example.test/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
experimental_bearer_token = "real-test-key"
|
||||
supports_websockets = true
|
||||
"#;
|
||||
let raw = format!(
|
||||
r#"model_provider = "custom"
|
||||
model = "test-model"
|
||||
model_reasoning_effort = "high"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "Primary"
|
||||
base_url = "http://127.0.0.1:45555/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
experimental_bearer_token = "{local_token}"
|
||||
supports_websockets = false
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
|
||||
[mcp_servers.example]
|
||||
command = "local-fixture-command"
|
||||
"#
|
||||
);
|
||||
fs::write(config_path(&dir), &raw).expect("write synthetic local route");
|
||||
let record = json!({
|
||||
"settings": {"enabled":false,"providerIds":[]},
|
||||
"journals": [{"primaryId":"fixture-primary", "providerKey":"custom",
|
||||
"originalTable":original, "port":45555, "token":local_token}],
|
||||
});
|
||||
app_db::open()
|
||||
.expect("open isolated test store")
|
||||
.execute(
|
||||
"INSERT INTO provider_failover (codex_dir, record_json) VALUES (?1, ?2)",
|
||||
params![paths::normalized_path_scope(&dir), record.to_string()],
|
||||
)
|
||||
.expect("seed owned route journal");
|
||||
Self {
|
||||
dir,
|
||||
raw,
|
||||
local_token,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OwnedRouteFixture {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(conn) = app_db::open() {
|
||||
let _ = conn.execute(
|
||||
"DELETE FROM provider_failover WHERE codex_dir = ?1",
|
||||
[paths::normalized_path_scope(&self.dir)],
|
||||
);
|
||||
}
|
||||
let _ = fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_route_state_detection_and_drafts_use_original_provider_without_writes() {
|
||||
let fixture = OwnedRouteFixture::new();
|
||||
let mut detected = providers::detected_live_custom_provider(&fixture.dir)
|
||||
.expect("detect logical provider")
|
||||
.expect("third-party provider");
|
||||
assert_eq!(detected.base_url, "https://primary.example.test/v1");
|
||||
assert_eq!(detected.api_key.as_deref(), Some("real-test-key"));
|
||||
assert!(!detected
|
||||
.toml_config
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains(&fixture.local_token));
|
||||
detected.toml_config = None;
|
||||
let draft = build_provider_toml_draft_inner(detected, Some(fixture.dir.display().to_string()))
|
||||
.expect("inherit direct route in editor draft");
|
||||
assert!(!draft.contains("127.0.0.1"));
|
||||
assert!(!draft.contains(&fixture.local_token));
|
||||
assert!(draft.contains("local-fixture-command"));
|
||||
let state =
|
||||
build_state_after_migration(fixture.dir.clone()).expect("read logical application state");
|
||||
let state_json = serde_json::to_value(&state).expect("serialize public state");
|
||||
assert_eq!(
|
||||
state_json["providers"][0]["baseUrl"],
|
||||
"https://primary.example.test/v1"
|
||||
);
|
||||
assert_eq!(state.model.as_deref(), Some("test-model"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&fixture.dir)).unwrap(),
|
||||
fixture.raw
|
||||
);
|
||||
assert!(!auth_path(&fixture.dir).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_managed_toml_submission_restores_owned_route_but_preserves_new_api_key() {
|
||||
let fixture = OwnedRouteFixture::new();
|
||||
for (input_key, expected_key) in [
|
||||
(
|
||||
Some(fixture.local_token.clone()),
|
||||
Some("real-test-key".to_string()),
|
||||
),
|
||||
(
|
||||
Some("new-user-key".to_string()),
|
||||
Some("new-user-key".to_string()),
|
||||
),
|
||||
(
|
||||
Some(format!(" {} ", fixture.local_token)),
|
||||
Some("real-test-key".to_string()),
|
||||
),
|
||||
(None, None),
|
||||
] {
|
||||
let input = direct_provider_toml_input(ProviderTomlInput {
|
||||
config_dir: Some(fixture.dir.display().to_string()),
|
||||
config_text: fixture.raw.replace("\"high\"", "\"low\""),
|
||||
api_key: input_key,
|
||||
})
|
||||
.expect("unwrap stale editor payload");
|
||||
assert_eq!(input.api_key, expected_key);
|
||||
let doc = input
|
||||
.config_text
|
||||
.parse::<DocumentMut>()
|
||||
.expect("parse direct payload");
|
||||
assert_eq!(doc["model_reasoning_effort"].as_str(), Some("low"));
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some("https://primary.example.test/v1")
|
||||
);
|
||||
assert!(!input.config_text.contains(&fixture.local_token));
|
||||
assert!(input.config_text.contains("local-fixture-command"));
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&fixture.dir)).unwrap(),
|
||||
fixture.raw
|
||||
);
|
||||
assert!(!auth_path(&fixture.dir).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_unwrap_handles_multiple_owned_tables_before_detecting_selected_provider() {
|
||||
let mut fixture = OwnedRouteFixture::new();
|
||||
let second_original = "name = \"Inactive\"\nbase_url = \"https://secondary.example.test/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = false\nexperimental_bearer_token = \"secondary-test-key\"\n";
|
||||
fixture.raw.push_str("\n[model_providers.inactive]\nname = \"Inactive\"\nbase_url = \"http://127.0.0.1:45556/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = false\nexperimental_bearer_token = \"secondary-local-token\"\n");
|
||||
fs::write(config_path(&fixture.dir), &fixture.raw).expect("write second local table");
|
||||
let conn = app_db::open().expect("read fixture store");
|
||||
let scope = paths::normalized_path_scope(&fixture.dir);
|
||||
let text: String = conn
|
||||
.query_row(
|
||||
"SELECT record_json FROM provider_failover WHERE codex_dir = ?1",
|
||||
[&scope],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("read recovery journal");
|
||||
let mut record: Value = serde_json::from_str(&text).expect("parse recovery journal");
|
||||
record["journals"].as_array_mut().unwrap().push(json!({
|
||||
"primaryId":"inactive", "providerKey":"inactive", "originalTable":second_original,
|
||||
"port":45556, "token":"secondary-local-token"
|
||||
}));
|
||||
conn.execute(
|
||||
"UPDATE provider_failover SET record_json = ?1 WHERE codex_dir = ?2",
|
||||
params![record.to_string(), scope],
|
||||
)
|
||||
.expect("append recovery identity");
|
||||
drop(conn);
|
||||
let detected = providers::detected_live_custom_provider(&fixture.dir)
|
||||
.expect("detect selected provider")
|
||||
.expect("selected third-party provider");
|
||||
assert_eq!(detected.base_url, "https://primary.example.test/v1");
|
||||
assert_eq!(detected.api_key.as_deref(), Some("real-test-key"));
|
||||
assert!(!detected.toml_config.unwrap().contains("127.0.0.1"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&fixture.dir)).unwrap(),
|
||||
fixture.raw
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_owned_route_keeps_official_status_quota_refresh_and_snapshot_credentials_scoped() {
|
||||
let _guard = app_db::test_db_guard();
|
||||
let mut fixture = OwnedRouteFixture::new();
|
||||
let original = "name = 'OpenAI'\nwire_api = 'responses'\nrequires_openai_auth = true\nsupports_websockets = true\n";
|
||||
fixture.raw = format!("model_provider='custom'\nmodel='official-model'\n[model_providers.custom]\nname='OpenAI'\nwire_api='responses'\nrequires_openai_auth=true\nsupports_websockets=false\nbase_url='http://127.0.0.1:45555/v1'\nhttp_headers={{'x-codex-x-route-token'='{}'}}\n[mcp_servers.keep]\ncommand='keep-native-mcp'\n", fixture.local_token);
|
||||
fs::write(config_path(&fixture.dir), &fixture.raw).unwrap();
|
||||
let record = json!({
|
||||
"version":2,
|
||||
"settings":{"routerEnabled":false,"takeoverEnabled":false,"autoFailoverEnabled":false,"providerIds":[]},
|
||||
"journals":[{"primaryId":"official:openai-official","providerKey":"custom","originalTable":original,
|
||||
"listenAddress":"127.0.0.1","port":45555,"token":fixture.local_token,"official":true,"tableExisted":true,"providersExisted":true}]
|
||||
});
|
||||
app_db::open()
|
||||
.unwrap()
|
||||
.execute(
|
||||
"UPDATE provider_failover SET record_json=?1 WHERE codex_dir=?2",
|
||||
params![
|
||||
record.to_string(),
|
||||
paths::normalized_path_scope(&fixture.dir)
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let auth = json!({"auth_mode":"chatgpt","tokens":{"access_token":"native-current-access","refresh_token":"native-refresh","account_id":"native-account"}});
|
||||
fs::write(auth_path(&fixture.dir), auth.to_string()).unwrap();
|
||||
let config_before = fs::read(config_path(&fixture.dir)).unwrap();
|
||||
let auth_before = fs::read(auth_path(&fixture.dir)).unwrap();
|
||||
let state = build_state_after_migration(fixture.dir.clone()).unwrap();
|
||||
assert!(state.is_official_provider);
|
||||
assert_eq!(
|
||||
state.active_official_profile_id.as_deref(),
|
||||
Some("openai-official")
|
||||
);
|
||||
assert!(providers::detected_live_custom_provider(&fixture.dir)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let profiles = list_official_profiles_inner(Some(fixture.dir.display().to_string())).unwrap();
|
||||
assert!(profiles[0].is_current);
|
||||
assert!(profiles[0].can_query_quota);
|
||||
let credentials = providers::official_profiles::official_profile_quota_credentials(
|
||||
&fixture.dir,
|
||||
"openai-official",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(credentials.access_token, "native-current-access");
|
||||
let route = failover::native_official::route_for_current(&fixture.dir)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(route.api_key.is_none());
|
||||
let spec = route.official.unwrap();
|
||||
assert!(failover::native_official::verify_request(
|
||||
&spec,
|
||||
"Bearer native-current-access",
|
||||
Some("native-account")
|
||||
)
|
||||
.is_ok());
|
||||
assert_eq!(fs::read(config_path(&fixture.dir)).unwrap(), config_before);
|
||||
assert_eq!(fs::read(auth_path(&fixture.dir)).unwrap(), auth_before);
|
||||
let refreshed = json!({"auth_mode":"chatgpt","tokens":{"access_token":"native-refreshed-access","refresh_token":"native-refreshed-refresh","account_id":"native-account"}});
|
||||
fs::write(auth_path(&fixture.dir), refreshed.to_string()).unwrap();
|
||||
assert_eq!(
|
||||
providers::official_profiles::official_profile_quota_credentials(
|
||||
&fixture.dir,
|
||||
"openai-official"
|
||||
)
|
||||
.unwrap()
|
||||
.access_token,
|
||||
"native-refreshed-access"
|
||||
);
|
||||
assert!(failover::native_official::verify_request(
|
||||
&spec,
|
||||
"Bearer native-current-access",
|
||||
Some("native-account")
|
||||
)
|
||||
.is_err());
|
||||
assert!(failover::native_official::verify_request(
|
||||
&spec,
|
||||
"Bearer native-refreshed-access",
|
||||
Some("native-account")
|
||||
)
|
||||
.is_ok());
|
||||
assert!(providers::capture_live_chatgpt_config(&fixture.dir).unwrap());
|
||||
let snapshot_path = providers::official_snapshot_path_for_test(&fixture.dir).unwrap();
|
||||
let snapshot: Value = serde_json::from_slice(&fs::read(&snapshot_path).unwrap()).unwrap();
|
||||
let saved_config = snapshot["config"].as_str().unwrap();
|
||||
assert!(!saved_config.contains("127.0.0.1"));
|
||||
assert!(!saved_config.contains("x-codex-x-route-token"));
|
||||
assert!(!saved_config.contains(&fixture.local_token));
|
||||
assert!(saved_config.contains("keep-native-mcp"));
|
||||
assert_eq!(
|
||||
snapshot["auth"]["tokens"]["access_token"],
|
||||
"native-refreshed-access"
|
||||
);
|
||||
assert_eq!(fs::read(config_path(&fixture.dir)).unwrap(), config_before);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&fs::read(auth_path(&fixture.dir)).unwrap()).unwrap(),
|
||||
refreshed
|
||||
);
|
||||
fs::remove_file(snapshot_path).unwrap();
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
mod app_db;
|
||||
mod app_update;
|
||||
mod backups;
|
||||
mod ccswitch;
|
||||
mod config_health;
|
||||
@@ -22,6 +23,7 @@ mod constants;
|
||||
mod context_config;
|
||||
mod desktop_lifecycle;
|
||||
mod error;
|
||||
mod failover;
|
||||
mod file_io;
|
||||
mod live_config;
|
||||
mod paths;
|
||||
@@ -236,6 +238,12 @@ pub(crate) fn sanitize_id(input: &str) -> String {
|
||||
}
|
||||
|
||||
fn default_codex_dir() -> Result<PathBuf> {
|
||||
// Explicit app-only override for isolated native validation and portable setups.
|
||||
if let Ok(value) = std::env::var("CODEXX_CONFIG_DIR") {
|
||||
if let Some(path) = codex_dir_from_text(&value)? {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
if let Ok(value) = std::env::var("CODEX_HOME") {
|
||||
if let Some(path) = codex_dir_from_text(&value)? {
|
||||
return Ok(path);
|
||||
@@ -608,9 +616,15 @@ async fn read_ccswitch_official_auth(
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_ccswitch_codex_providers(db_path: Option<String>) -> Result<ImportResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || import_ccswitch_codex_providers_inner(db_path))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("导入 cc-switch Provider 失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = import_ccswitch_codex_providers_inner(db_path)?;
|
||||
failover::refresh_saved_routes().map_err(|error| {
|
||||
CodexxError::Config(format!("供应商已导入,但自动切换状态更新失败:{error}"))
|
||||
})?;
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("导入 cc-switch Provider 失败: {e}")))?
|
||||
}
|
||||
|
||||
fn get_about_info_inner(config_dir: Option<String>) -> Result<AboutInfo> {
|
||||
@@ -930,6 +944,25 @@ async fn list_saved_providers() -> Result<Vec<SavedProvider>> {
|
||||
.map_err(|e| CodexxError::Config(format!("读取供应商列表失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_provider_order(config_dir: Option<String>) -> Result<Vec<String>> {
|
||||
tauri::async_runtime::spawn_blocking(move || providers::get_provider_order_inner(config_dir))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("读取供应商排序失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_provider_order(
|
||||
config_dir: Option<String>,
|
||||
order: Vec<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
providers::save_provider_order_inner(config_dir, order)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("保存供应商排序失败: {error}")))?
|
||||
}
|
||||
|
||||
enum ActiveProviderSelectionUpdate {
|
||||
Set(String),
|
||||
ClearIfOfficial,
|
||||
@@ -978,6 +1011,15 @@ fn finish_provider_selection(
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_provider_config_base(config_dir: Option<String>) -> Result<String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
providers::get_provider_config_base_inner(config_dir)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("读取供应商通用配置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn build_provider_toml_draft(
|
||||
provider: SavedProvider,
|
||||
@@ -996,7 +1038,11 @@ async fn build_provider_toml_draft(
|
||||
}
|
||||
|
||||
fn save_provider_command_inner(provider: SavedProvider) -> Result<SavedProvider> {
|
||||
save_provider_inner(provider)
|
||||
let saved = save_provider_inner(provider)?;
|
||||
failover::refresh_saved_routes().map_err(|error| {
|
||||
CodexxError::Config(format!("供应商已保存,但自动切换状态更新失败:{error}"))
|
||||
})?;
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1042,17 +1088,46 @@ async fn get_usage_statistics(
|
||||
.map_err(|error| CodexxError::Config(format!("读取用量统计失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_provider_failover(config_dir: Option<String>) -> Result<failover::FailoverStatus> {
|
||||
tauri::async_runtime::spawn_blocking(move || failover::get_status(config_dir))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("读取自动切换设置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_provider_failover(
|
||||
config_dir: Option<String>,
|
||||
settings: failover::FailoverSettings,
|
||||
) -> Result<failover::FailoverStatus> {
|
||||
tauri::async_runtime::spawn_blocking(move || failover::save_settings(config_dir, settings))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("保存自动切换设置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn reset_provider_failover_health(
|
||||
config_dir: Option<String>,
|
||||
provider_id: Option<String>,
|
||||
) -> Result<failover::FailoverStatus> {
|
||||
tauri::async_runtime::spawn_blocking(move || failover::reset_health(config_dir, provider_id))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("重置供应商健康状态失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn activate_saved_provider(
|
||||
config_dir: Option<String>,
|
||||
provider_id: String,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = providers::activate_saved_provider_inner(config_dir, provider_id.clone())?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
))
|
||||
failover::with_provider_change(config_dir.clone(), || {
|
||||
let result = providers::activate_saved_provider_inner(config_dir, provider_id.clone())?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("启用供应商失败: {error}")))?
|
||||
@@ -1062,14 +1137,25 @@ async fn activate_saved_provider(
|
||||
async fn save_active_provider(
|
||||
provider: SavedProvider,
|
||||
config_dir: Option<String>,
|
||||
apply_common_config: Option<bool>,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let provider_id = provider.id.clone();
|
||||
let result = save_active_provider_inner(provider, config_dir)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
))
|
||||
let result = failover::with_provider_change(config_dir.clone(), || {
|
||||
let provider_id = provider.id.clone();
|
||||
let result = if apply_common_config.unwrap_or(false) {
|
||||
providers::save_active_provider_with_common_config_inner(provider, config_dir)?
|
||||
} else {
|
||||
save_active_provider_inner(provider, config_dir)?
|
||||
};
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
))
|
||||
})?;
|
||||
failover::refresh_saved_routes().map_err(|error| {
|
||||
CodexxError::Config(format!("供应商已保存,但自动切换状态更新失败:{error}"))
|
||||
})?;
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存活动供应商失败: {e}")))?
|
||||
@@ -1077,9 +1163,32 @@ async fn save_active_provider(
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_saved_provider(id: String, config_dir: Option<String>) -> Result<()> {
|
||||
tauri::async_runtime::spawn_blocking(move || delete_saved_provider_inner(id.trim(), config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("删除供应商失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
delete_saved_provider_inner(id.trim(), config_dir)?;
|
||||
failover::refresh_saved_routes().map_err(|error| {
|
||||
CodexxError::Config(format!("供应商已删除,但自动切换状态更新失败:{error}"))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("删除供应商失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn repair_provider_model_catalog(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<providers::model_catalog::ProviderModelCatalogRepairResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
let changed = providers::model_catalog::upgrade_owned_model_catalog(&codex_dir)?;
|
||||
Ok(providers::model_catalog::ProviderModelCatalogRepairResult {
|
||||
changed,
|
||||
message: changed.then(|| {
|
||||
"已修复供应商模型菜单的图片输入和思考强度,请重启 Codex 后使用".to_string()
|
||||
}),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("修复供应商模型菜单失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1097,14 +1206,16 @@ fn get_codex_state_inner(config_dir: Option<String>) -> Result<CodexState> {
|
||||
#[tauri::command]
|
||||
async fn switch_official_provider(config_dir: Option<String>) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = switch_official_profile_inner(
|
||||
config_dir,
|
||||
providers::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID.to_string(),
|
||||
)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
failover::with_provider_change(config_dir.clone(), || {
|
||||
let result = switch_official_profile_inner(
|
||||
config_dir,
|
||||
providers::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID.to_string(),
|
||||
)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("切换官方配置失败: {e}")))?
|
||||
@@ -1153,9 +1264,26 @@ async fn get_official_profile(
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_official_profile(input: OfficialProfileInput) -> Result<OfficialProfileActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || save_official_profile_inner(input))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("保存官方配置失败: {error}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
// Saving a separate account is a library edit, not a manual route change.
|
||||
let applies_live = match input.id.as_ref() {
|
||||
Some(id) => {
|
||||
get_official_profile_inner(input.config_dir.clone(), id.clone())?
|
||||
.profile
|
||||
.is_current
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if applies_live {
|
||||
failover::with_provider_change(input.config_dir.clone(), || {
|
||||
save_official_profile_inner(input)
|
||||
})
|
||||
} else {
|
||||
save_official_profile_inner(input)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("保存官方配置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1177,7 +1305,9 @@ async fn switch_official_profile(
|
||||
profile_id: String,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
switch_official_profile_inner(config_dir, profile_id)
|
||||
failover::with_provider_change(config_dir.clone(), || {
|
||||
switch_official_profile_inner(config_dir, profile_id)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("切换官方配置失败: {error}")))?
|
||||
@@ -1195,11 +1325,13 @@ async fn delete_official_profile(config_dir: Option<String>, profile_id: String)
|
||||
#[tauri::command]
|
||||
async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = providers::official_profiles::reset_default_official_profile_inner(input)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
failover::with_provider_change(input.config_dir.clone(), || {
|
||||
let result = providers::official_profiles::reset_default_official_profile_inner(input)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("新建官方配置失败: {e}")))?
|
||||
@@ -1208,16 +1340,18 @@ async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionRes
|
||||
#[tauri::command]
|
||||
async fn save_official_config(input: OfficialConfigInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = save_official_config_inner(
|
||||
input.config_dir,
|
||||
input.model,
|
||||
input.auth_json,
|
||||
input.config_text,
|
||||
)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
failover::with_provider_change(input.config_dir.clone(), || {
|
||||
let result = save_official_config_inner(
|
||||
input.config_dir,
|
||||
input.model,
|
||||
input.auth_json,
|
||||
input.config_text,
|
||||
)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存官方配置失败: {e}")))?
|
||||
@@ -1396,18 +1530,41 @@ async fn disable_external_instruction(config_dir: Option<String>) -> Result<Acti
|
||||
.map_err(|e| CodexxError::Config(format!("禁用外部提示词失败: {e}")))?
|
||||
}
|
||||
|
||||
fn direct_provider_toml_input(mut input: ProviderTomlInput) -> Result<ProviderTomlInput> {
|
||||
let codex_dir = resolve_codex_dir(input.config_dir.clone())?;
|
||||
let doc = parse_toml_document(&config_path(&codex_dir), &input.config_text)?;
|
||||
let direct = failover::direct_document(&codex_dir, &doc)?;
|
||||
if direct.to_string() != doc.to_string() {
|
||||
let old_id = string_value(&doc, "model_provider");
|
||||
let old_token = providers::experimental_bearer_token_from_doc(&doc, old_id.as_deref());
|
||||
// Only replace the known local credential echoed back by the editor.
|
||||
// A newly entered key belongs to the user's edit and must be retained.
|
||||
if old_token.is_some() && input.api_key.as_deref().map(str::trim) == old_token.as_deref() {
|
||||
let direct_id = string_value(&direct, "model_provider");
|
||||
input.api_key =
|
||||
providers::experimental_bearer_token_from_doc(&direct, direct_id.as_deref());
|
||||
}
|
||||
input.config_text = direct.to_string();
|
||||
}
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_provider_toml_config(
|
||||
input: ProviderTomlInput,
|
||||
provider_id: Option<String>,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = save_provider_toml_config_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => {
|
||||
finish_provider_selection(result, ActiveProviderSelectionUpdate::Set(provider_id))
|
||||
}
|
||||
None => result,
|
||||
failover::with_provider_change(input.config_dir.clone(), || {
|
||||
let input = direct_provider_toml_input(input)?;
|
||||
let result = save_provider_toml_config_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
),
|
||||
None => result,
|
||||
})
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -1437,13 +1594,16 @@ async fn fetch_provider_models(
|
||||
#[tauri::command]
|
||||
async fn switch_provider(input: ProviderInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let provider_id = input.provider_id.clone();
|
||||
let result = switch_provider_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => {
|
||||
finish_provider_selection(result, ActiveProviderSelectionUpdate::Set(provider_id))
|
||||
}
|
||||
None => result,
|
||||
failover::with_provider_change(input.config_dir.clone(), || {
|
||||
let provider_id = input.provider_id.clone();
|
||||
let result = switch_provider_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
),
|
||||
None => result,
|
||||
})
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -1534,11 +1694,14 @@ fn restore_backup_inner(config_dir: Option<String>, backup_id: String) -> Result
|
||||
let backup_config = match backup_config {
|
||||
Some(bytes) => {
|
||||
let text = text_from_snapshot(&backup_cfg, Some(&bytes))?;
|
||||
Some(
|
||||
migrated_legacy_prompt_config_text(&cfg, &text)?
|
||||
.unwrap_or(text)
|
||||
.into_bytes(),
|
||||
)
|
||||
let text = migrated_legacy_prompt_config_text(&cfg, &text)?.unwrap_or(text);
|
||||
let doc = parse_toml_document(&cfg, &text)?;
|
||||
let direct = failover::direct_document(&codex_dir, &doc)?;
|
||||
Some(if direct.to_string() == doc.to_string() {
|
||||
text.into_bytes()
|
||||
} else {
|
||||
direct.to_string().into_bytes()
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
@@ -1578,9 +1741,16 @@ fn restore_backup_inner(config_dir: Option<String>, backup_id: String) -> Result
|
||||
|
||||
#[tauri::command]
|
||||
async fn restore_backup(config_dir: Option<String>, backup_id: String) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || restore_backup_inner(config_dir, backup_id))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("恢复备份失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
failover::with_provider_change(config_dir.clone(), || {
|
||||
let mut result = restore_backup_inner(config_dir.clone(), backup_id)?;
|
||||
failover::recover_stale_route(config_dir)?;
|
||||
result.state = build_state_after_migration(PathBuf::from(&result.state.codex_dir))?;
|
||||
Ok(result)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("恢复备份失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1629,10 +1799,19 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.setup(|app| {
|
||||
desktop_lifecycle::setup_system_tray(app)?;
|
||||
failover::attach_app_handle(app.handle().clone());
|
||||
if let Err(error) = failover::initialize() {
|
||||
desktop_lifecycle::report_failover_lifecycle_error(
|
||||
app.handle(),
|
||||
"自动切换暂时无法启动",
|
||||
&error.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(desktop_lifecycle::handle_window_event)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
app_update::install_app_update,
|
||||
get_about_info,
|
||||
restart_codex_desktop,
|
||||
check_app_update,
|
||||
@@ -1666,15 +1845,22 @@ pub fn run() {
|
||||
delete_saved_prompt,
|
||||
enable_saved_prompt,
|
||||
list_saved_providers,
|
||||
get_provider_order,
|
||||
save_provider_order,
|
||||
get_provider_config_base,
|
||||
build_provider_toml_draft,
|
||||
save_provider,
|
||||
duplicate_provider,
|
||||
update_codex_context_window,
|
||||
get_usage_statistics,
|
||||
get_provider_failover,
|
||||
save_provider_failover,
|
||||
reset_provider_failover_health,
|
||||
save_active_provider,
|
||||
activate_saved_provider,
|
||||
delete_saved_provider,
|
||||
get_codex_state,
|
||||
repair_provider_model_catalog,
|
||||
switch_official_provider,
|
||||
list_official_profiles,
|
||||
get_official_profile,
|
||||
@@ -1706,3 +1892,6 @@ pub fn run() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod failover_integration_tests;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,13 +27,15 @@ pub(crate) use connection::{
|
||||
fetch_provider_models_inner, test_provider_connection_inner, ProviderConnectionResult,
|
||||
ProviderModelsResult,
|
||||
};
|
||||
pub(crate) use live::detected_live_custom_provider;
|
||||
pub(crate) use live::{
|
||||
activate_saved_provider_inner, build_provider_toml_draft_inner,
|
||||
build_provider_toml_draft_with_origin_inner, delete_saved_provider_inner,
|
||||
save_active_provider_inner, save_official_config_inner, save_provider_toml_config_inner,
|
||||
switch_provider_inner, OfficialConfigInput, ProviderInput, ProviderTomlInput,
|
||||
get_provider_config_base_inner, save_active_provider_inner,
|
||||
save_active_provider_with_common_config_inner, save_official_config_inner,
|
||||
save_provider_toml_config_inner, switch_provider_inner, OfficialConfigInput, ProviderInput,
|
||||
ProviderTomlInput,
|
||||
};
|
||||
pub(crate) use live::{detected_live_custom_provider, replacement_write_order, LiveWriteOrder};
|
||||
#[cfg(test)]
|
||||
pub(crate) use live::{
|
||||
reset_official_provider_inner, restore_official_provider_inner,
|
||||
@@ -57,12 +59,12 @@ pub(crate) use store::{
|
||||
pub(crate) use store::{
|
||||
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
|
||||
delete_provider_inner, duplicate_provider_inner, experimental_bearer_token_from_doc,
|
||||
is_placeholder_provider, list_saved_providers_inner, list_saved_providers_on_connection,
|
||||
matching_saved_provider_ids_for_live, matching_saved_provider_ids_for_live_on_connection,
|
||||
normalize_saved_provider, normalize_saved_provider_for_save, provider_template_from_document,
|
||||
reserved_codex_provider_id, rollback_provider_store_inner,
|
||||
save_detected_provider_with_rollback_inner, save_provider_inner,
|
||||
save_provider_with_rollback_inner, strip_provider_bearer_tokens,
|
||||
get_provider_order_inner, is_placeholder_provider, list_saved_providers_inner,
|
||||
list_saved_providers_on_connection, matching_saved_provider_ids_for_live,
|
||||
matching_saved_provider_ids_for_live_on_connection, normalize_saved_provider,
|
||||
normalize_saved_provider_for_save, provider_template_from_document, reserved_codex_provider_id,
|
||||
rollback_provider_store_inner, save_detected_provider_with_rollback_inner, save_provider_inner,
|
||||
save_provider_order_inner, save_provider_with_rollback_inner, strip_provider_bearer_tokens,
|
||||
upsert_ccswitch_provider_on_connection, DuplicateProviderResult, ProviderStoreRollback,
|
||||
ProviderUpsertKind, SavedProvider,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, write_private_json};
|
||||
use crate::live_config::{
|
||||
acquire_live_config_lock, atomic_write_if_unchanged, ensure_file_snapshot_unchanged,
|
||||
read_file_snapshot, text_from_snapshot,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -37,6 +41,13 @@ pub(crate) struct ProviderModelMapping {
|
||||
pub(crate) context_window: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ProviderModelCatalogRepairResult {
|
||||
pub(crate) changed: bool,
|
||||
pub(crate) message: Option<String>,
|
||||
}
|
||||
|
||||
fn validate_name(name: &str, label: &str) -> Result<String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
@@ -111,8 +122,9 @@ fn model_entry(mapping: &ProviderModelMapping, priority: usize, default_context:
|
||||
// Schema: openai/codex rust-v0.153.4, protocol/src/openai_models.rs,
|
||||
// ModelInfo + ModelsResponse. Keep legacy required fields for older Codex.
|
||||
// Unlike cloning a GPT cache entry, this does not import proprietary model
|
||||
// instructions, hosted tools, service tiers or vision. Expose the same effort
|
||||
// menu for mapped models; the upstream API determines each effort's effect.
|
||||
// instructions, hosted tools or service tiers. Allow image attachments instead
|
||||
// of making the client reject every third-party model before contacting its
|
||||
// provider. The provider determines the supported modalities and efforts.
|
||||
json!({
|
||||
"slug": mapping.model,
|
||||
"display_name": mapping.display_name,
|
||||
@@ -142,7 +154,7 @@ fn model_entry(mapping: &ProviderModelMapping, priority: usize, default_context:
|
||||
"max_context_window": context_window,
|
||||
"effective_context_window_percent": 95,
|
||||
"experimental_supported_tools": [],
|
||||
"input_modalities": ["text"],
|
||||
"input_modalities": ["text", "image"],
|
||||
"supports_search_tool": false,
|
||||
"use_responses_lite": false,
|
||||
"prefer_websockets": false
|
||||
@@ -344,6 +356,15 @@ pub(crate) fn prepare_model_catalog(
|
||||
"source": "codex-x", "format": 1,
|
||||
"provider_hash": format!("{:x}", Sha256::digest(provider_id.as_bytes()))
|
||||
});
|
||||
let path = persist_owned_catalog(codex_dir, &catalog)?;
|
||||
// Change the caller's document only after a complete catalog is available.
|
||||
// The caller owns the live config transaction; no auth/config files are
|
||||
// read or written here, and models_cache.json is intentionally untouched.
|
||||
doc[CATALOG_FIELD] = value(path.to_string_lossy().to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_owned_catalog(codex_dir: &Path, catalog: &Value) -> Result<PathBuf> {
|
||||
let filename = catalog_filename(&catalog)?;
|
||||
let directory = prepare_owned_directory(codex_dir)?;
|
||||
let path = directory.join(filename);
|
||||
@@ -355,22 +376,101 @@ pub(crate) fn prepare_model_catalog(
|
||||
));
|
||||
}
|
||||
let existing = fs::read(&path).map_err(|error| io_err(&path, error))?;
|
||||
if serde_json::from_slice::<Value>(&existing).ok().as_ref() != Some(&catalog) {
|
||||
if serde_json::from_slice::<Value>(&existing).ok().as_ref() != Some(catalog) {
|
||||
return Err(CodexxError::Config(
|
||||
"已生成的模型目录文件被修改,未覆盖原文件".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
write_private_json(&path, &catalog)?
|
||||
write_private_json(&path, catalog)?
|
||||
}
|
||||
Err(error) => return Err(io_err(&path, error)),
|
||||
}
|
||||
// Change the caller's document only after a complete catalog is available.
|
||||
// The caller owns the live config transaction; no auth/config files are
|
||||
// read or written here, and models_cache.json is intentionally untouched.
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Upgrade a verified Codex-X catalog already selected by an older app. Keep
|
||||
/// the old immutable file available for backups and failed provider switches.
|
||||
/// User catalogs, edited generated files and official configurations are skipped.
|
||||
pub(crate) fn upgrade_owned_model_catalog(codex_dir: &Path) -> Result<bool> {
|
||||
let cfg = crate::config_path(codex_dir);
|
||||
let original = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, original.as_deref())?;
|
||||
if text.trim().is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut doc = crate::file_io::parse_toml_document(&cfg, &text)?;
|
||||
if super::document_is_official(&doc) {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(pointer) = doc.get(CATALOG_FIELD).and_then(|item| item.as_str()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !is_owned_pointer(codex_dir, pointer) {
|
||||
return Ok(false);
|
||||
}
|
||||
let pointer = Path::new(pointer);
|
||||
let source = if pointer.is_absolute() {
|
||||
pointer.to_path_buf()
|
||||
} else {
|
||||
codex_dir.join(pointer)
|
||||
};
|
||||
let Some(filename) = source.file_name().and_then(|name| name.to_str()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
// Ownership is stronger than the path-only recognition used for removing
|
||||
// stale pointers: only a digest-verified generated catalog may be upgraded.
|
||||
if !has_verified_ownership(&source, filename) {
|
||||
return Ok(false);
|
||||
}
|
||||
let bytes = fs::read(&source).map_err(|error| io_err(&source, error))?;
|
||||
let mut catalog: Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|_| CodexxError::Config("供应商模型目录不是有效的 JSON".into()))?;
|
||||
// Recheck the read snapshot rather than trusting the first verification,
|
||||
// since a user or another process can edit the source between reads.
|
||||
if catalog_filename(&catalog)? != filename {
|
||||
return Ok(false);
|
||||
}
|
||||
let before = catalog.clone();
|
||||
let capabilities = model_entry(
|
||||
&ProviderModelMapping {
|
||||
model: String::new(),
|
||||
display_name: String::new(),
|
||||
context_window: None,
|
||||
},
|
||||
0,
|
||||
DEFAULT_CONTEXT_WINDOW,
|
||||
);
|
||||
for model in catalog["models"]
|
||||
.as_array_mut()
|
||||
.expect("verified catalogs contain a models array")
|
||||
{
|
||||
if !model.is_object() {
|
||||
return Ok(false);
|
||||
}
|
||||
for key in [
|
||||
"input_modalities",
|
||||
"default_reasoning_level",
|
||||
"supported_reasoning_levels",
|
||||
"supports_reasoning_summaries",
|
||||
"supports_reasoning_summary_parameter",
|
||||
"default_reasoning_summary",
|
||||
] {
|
||||
model[key] = capabilities[key].clone();
|
||||
}
|
||||
}
|
||||
if catalog == before {
|
||||
return Ok(false);
|
||||
}
|
||||
let _lock = acquire_live_config_lock(codex_dir)?;
|
||||
ensure_file_snapshot_unchanged(&cfg, original.as_deref())?;
|
||||
ensure_file_snapshot_unchanged(&source, Some(&bytes))?;
|
||||
let path = persist_owned_catalog(codex_dir, &catalog)?;
|
||||
doc[CATALOG_FIELD] = value(path.to_string_lossy().to_string());
|
||||
Ok(())
|
||||
crate::backups::create_backup(codex_dir, "upgrade-provider-model-capabilities")?;
|
||||
atomic_write_if_unchanged(&cfg, original.as_deref(), doc.to_string().as_bytes())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -418,6 +518,7 @@ mod tests {
|
||||
"deepseek-v4-pro",
|
||||
"vendor/custom-model",
|
||||
"gpt-5.5",
|
||||
"gpt-6-astra",
|
||||
] {
|
||||
let entry = model_entry(&mapping(id, id, None), 0, 128000);
|
||||
let levels: Vec<_> = entry["supported_reasoning_levels"]
|
||||
@@ -537,7 +638,7 @@ mod tests {
|
||||
assert_eq!(model["display_name"], "DeepSeek 常用");
|
||||
assert_eq!(model["context_window"], 131072);
|
||||
assert_eq!(model["apply_patch_tool_type"], Value::Null);
|
||||
assert_eq!(model["input_modalities"], json!(["text"]));
|
||||
assert_eq!(model["input_modalities"], json!(["text", "image"]));
|
||||
assert!(!catalog.to_string().contains("gpt-5.5"));
|
||||
assert!(!catalog.to_string().contains("PRIVATE"));
|
||||
assert!(!catalog.to_string().contains("namespace-secret"));
|
||||
@@ -605,6 +706,148 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn legacy_catalog(fixture: &Fixture, doc: &mut DocumentMut) -> (PathBuf, Vec<u8>) {
|
||||
prepare_model_catalog(
|
||||
&fixture.0,
|
||||
"provider",
|
||||
&[
|
||||
mapping("deepseek-flash", "DeepSeek Flash", Some(131072)),
|
||||
mapping("gpt-6-astra", "GPT-6 Astra", Some(1_000_000)),
|
||||
],
|
||||
"deepseek-flash",
|
||||
doc,
|
||||
)
|
||||
.unwrap();
|
||||
let mut legacy = catalog(doc);
|
||||
for model in legacy["models"].as_array_mut().unwrap() {
|
||||
model["input_modalities"] = json!(["text"]);
|
||||
model["supported_reasoning_levels"] = json!([
|
||||
{"effort": "low", "description": "Low effort"},
|
||||
{"effort": "max", "description": "Maximum effort"}
|
||||
]);
|
||||
model["supports_reasoning_summaries"] = json!(false);
|
||||
}
|
||||
let filename = catalog_filename(&legacy).unwrap();
|
||||
let path = owned_catalog_directory(&fixture.0).join(filename);
|
||||
let bytes = serde_json::to_vec(&legacy).unwrap();
|
||||
fs::write(&path, &bytes).unwrap();
|
||||
doc[CATALOG_FIELD] = value(path.to_string_lossy().to_string());
|
||||
(path, bytes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrades_active_legacy_capabilities_without_touching_credentials_or_old_catalog() {
|
||||
let fixture = Fixture::new();
|
||||
let mut doc: DocumentMut = r#"# Keep this comment.
|
||||
model_provider = "custom"
|
||||
model = "deepseek-flash"
|
||||
model_reasoning_effort = "ultra"
|
||||
approval_policy = "never"
|
||||
[model_providers.custom]
|
||||
name = "DeepSeek"
|
||||
base_url = "https://fixture.invalid/v1"
|
||||
wire_api = "responses"
|
||||
"#
|
||||
.parse()
|
||||
.unwrap();
|
||||
let (old_path, old_bytes) = legacy_catalog(&fixture, &mut doc);
|
||||
fs::write(fixture.0.join("config.toml"), doc.to_string()).unwrap();
|
||||
fs::write(fixture.0.join("auth.json"), "untouched-fixture-credential").unwrap();
|
||||
fs::write(fixture.0.join("models_cache.json"), "untouched-model-cache").unwrap();
|
||||
|
||||
assert!(upgrade_owned_model_catalog(&fixture.0).unwrap());
|
||||
let updated: DocumentMut = fs::read_to_string(fixture.0.join("config.toml"))
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert_ne!(path(&updated), old_path);
|
||||
assert_eq!(fs::read(old_path).unwrap(), old_bytes);
|
||||
assert_eq!(updated["model"].as_str(), Some("deepseek-flash"));
|
||||
assert_eq!(updated["model_reasoning_effort"].as_str(), Some("ultra"));
|
||||
assert_eq!(updated["approval_policy"].as_str(), Some("never"));
|
||||
assert!(updated.to_string().contains("# Keep this comment."));
|
||||
assert_eq!(
|
||||
updated["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some("https://fixture.invalid/v1")
|
||||
);
|
||||
let upgraded = catalog(&updated);
|
||||
assert_eq!(upgraded["models"][0]["slug"], "deepseek-flash");
|
||||
assert_eq!(upgraded["models"][1]["slug"], "gpt-6-astra");
|
||||
assert_eq!(upgraded["models"][0]["context_window"], 131072);
|
||||
assert_eq!(upgraded["models"][1]["context_window"], 1_000_000);
|
||||
for model in upgraded["models"].as_array().unwrap() {
|
||||
assert_eq!(model["input_modalities"], json!(["text", "image"]));
|
||||
assert_eq!(model["supports_reasoning_summaries"], true);
|
||||
assert_eq!(
|
||||
model["supported_reasoning_levels"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.last()
|
||||
.unwrap()["effort"],
|
||||
"ultra"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("auth.json")).unwrap(),
|
||||
"untouched-fixture-credential"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("models_cache.json")).unwrap(),
|
||||
"untouched-model-cache"
|
||||
);
|
||||
let config_after = fs::read(fixture.0.join("config.toml")).unwrap();
|
||||
assert!(!upgrade_owned_model_catalog(&fixture.0).unwrap());
|
||||
assert_eq!(
|
||||
fs::read(fixture.0.join("config.toml")).unwrap(),
|
||||
config_after
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_upgrade_skips_official_and_unverified_catalogs() {
|
||||
let fixture = Fixture::new();
|
||||
let mut doc: DocumentMut = "model_provider = 'custom'\nmodel = 'deepseek-flash'"
|
||||
.parse()
|
||||
.unwrap();
|
||||
let (old_path, old_bytes) = legacy_catalog(&fixture, &mut doc);
|
||||
for provider in ["openai", "custom"] {
|
||||
doc["model_provider"] = value(provider);
|
||||
let config = doc.to_string();
|
||||
fs::write(fixture.0.join("config.toml"), &config).unwrap();
|
||||
if provider == "custom" {
|
||||
let mut modified: Value = serde_json::from_slice(&old_bytes).unwrap();
|
||||
modified["models"][0]["display_name"] = json!("User's own menu");
|
||||
fs::write(&old_path, serde_json::to_vec(&modified).unwrap()).unwrap();
|
||||
}
|
||||
assert!(!upgrade_owned_model_catalog(&fixture.0).unwrap());
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("config.toml")).unwrap(),
|
||||
config
|
||||
);
|
||||
}
|
||||
doc[CATALOG_FIELD] = value("my-models.json");
|
||||
let config = doc.to_string();
|
||||
fs::write(fixture.0.join("config.toml"), &config).unwrap();
|
||||
fs::write(fixture.0.join("my-models.json"), &old_bytes).unwrap();
|
||||
assert!(!upgrade_owned_model_catalog(&fixture.0).unwrap());
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("config.toml")).unwrap(),
|
||||
config
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(fixture.0.join("my-models.json")).unwrap(),
|
||||
old_bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_config_upgrade_does_not_create_a_codex_home() {
|
||||
let fixture = Fixture::new();
|
||||
let missing = fixture.0.join("missing-codex-home");
|
||||
assert!(!upgrade_owned_model_catalog(&missing).unwrap());
|
||||
assert!(!missing.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_removes_only_our_pointer_without_deleting_catalog() {
|
||||
let fixture = Fixture::new();
|
||||
|
||||
@@ -201,7 +201,13 @@ fn live_official_config_text(codex_dir: &Path) -> Result<Option<String>> {
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
|
||||
let doc = parse_toml_document(&path, &text)?;
|
||||
Ok(document_is_official(&doc).then_some(text))
|
||||
let direct = crate::failover::direct_document(codex_dir, &doc)?;
|
||||
let text = if direct.to_string() == doc.to_string() {
|
||||
text
|
||||
} else {
|
||||
direct.to_string()
|
||||
};
|
||||
Ok(document_is_official(&direct).then_some(text))
|
||||
}
|
||||
|
||||
fn remove_bearer_tokens(doc: &mut toml_edit::DocumentMut) {
|
||||
@@ -229,7 +235,8 @@ pub(crate) fn build_official_config_text(
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let mut doc = parse_toml_document(&path, &text)?;
|
||||
let doc = parse_toml_document(&path, &text)?;
|
||||
let mut doc = crate::failover::direct_document(codex_dir, &doc)?;
|
||||
|
||||
doc["model_provider"] = value("custom");
|
||||
doc.as_table_mut().remove("base_url");
|
||||
@@ -302,6 +309,7 @@ pub(crate) fn live_config_is_official(codex_dir: &Path) -> Result<bool> {
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
|
||||
let doc = parse_toml_document(&path, &text)?;
|
||||
let doc = crate::failover::direct_document(codex_dir, &doc)?;
|
||||
Ok(document_is_official(&doc))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::live::{
|
||||
apply_official_config_with_snapshot_locked, persist_detected_live_custom_provider,
|
||||
read_live_file_snapshot, AppliedLiveFiles, LiveAuthAction,
|
||||
apply_official_config_with_snapshot_locked, official_activation_config_text,
|
||||
persist_detected_live_custom_provider, read_live_file_snapshot, AppliedLiveFiles,
|
||||
LiveAuthAction,
|
||||
};
|
||||
use super::official_auth::{
|
||||
build_official_config_text, capture_live_official_config_before_provider_switch,
|
||||
@@ -436,6 +437,7 @@ pub(crate) fn list_official_profiles_inner(
|
||||
let selected = selected_profile_id(&codex_dir)?;
|
||||
let config_path = crate::config_path(&codex_dir);
|
||||
let config = parse_toml_document(&config_path, &read_to_string_if_exists(&config_path)?)?;
|
||||
let config = crate::failover::direct_document(&codex_dir, &config)?;
|
||||
let is_official = document_is_official(&config);
|
||||
let live_model = crate::string_value(&config, "model");
|
||||
let live_auth = is_official
|
||||
@@ -867,6 +869,7 @@ fn switch_official_profile_with_before_apply(
|
||||
&target.config_text,
|
||||
target.profile.model.as_deref(),
|
||||
)?;
|
||||
let config = official_activation_config_text(&codex_dir, &config)?;
|
||||
let auth = parse_auth(&target.auth_json)?;
|
||||
with_mutation(&codex_dir, |mutation| {
|
||||
let previous_id = selected_profile_id(&codex_dir)?;
|
||||
@@ -1533,10 +1536,14 @@ mod tests {
|
||||
Some(b.profile.id.as_str())
|
||||
);
|
||||
assert_eq!(live_auth(&dir), auth("b"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&dir)).unwrap().trim_end(),
|
||||
config("b").trim_end()
|
||||
);
|
||||
let configured = fs::read_to_string(config_path(&dir))
|
||||
.unwrap()
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.unwrap();
|
||||
assert_eq!(configured["model"].as_str(), Some("model-b"));
|
||||
assert!(document_is_official(&configured));
|
||||
assert_eq!(configured["approval_policy"].as_str(), Some("never"));
|
||||
assert_eq!(configured["features"]["web_search"].as_bool(), Some(true));
|
||||
|
||||
let refreshed = auth("b-refreshed");
|
||||
write_json(&auth_path(&dir), &refreshed).unwrap();
|
||||
@@ -1569,10 +1576,14 @@ mod tests {
|
||||
write_json(&auth_path(&dir), &auth("a-refreshed")).unwrap();
|
||||
switch(&dir, &b.profile.id);
|
||||
assert_eq!(live_auth(&dir), refreshed);
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&dir)).unwrap().trim_end(),
|
||||
b_config.trim_end()
|
||||
);
|
||||
let configured = fs::read_to_string(config_path(&dir))
|
||||
.unwrap()
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.unwrap();
|
||||
assert_eq!(configured["model"].as_str(), Some("model-b"));
|
||||
assert!(document_is_official(&configured));
|
||||
assert_eq!(configured["desktop"]["notify"].as_bool(), Some(true));
|
||||
assert_eq!(configured["features"]["web_search"].as_bool(), Some(true));
|
||||
switch(&dir, DEFAULT_OFFICIAL_PROFILE_ID);
|
||||
assert_eq!(live_auth(&dir), auth("a-refreshed"));
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use super::{clear_provider_selections_on_connection, open_store as open_db};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::{now_rfc3339, sanitize_id};
|
||||
use rusqlite::{params, Connection, TransactionBehavior};
|
||||
use crate::paths::normalized_path_scope;
|
||||
use crate::{now_rfc3339, resolve_codex_dir, sanitize_id};
|
||||
use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use toml_edit::{value, DocumentMut};
|
||||
@@ -411,6 +412,94 @@ pub(crate) fn list_saved_providers_inner() -> Result<Vec<SavedProvider>> {
|
||||
list_saved_providers_on_connection(&conn)
|
||||
}
|
||||
|
||||
fn validate_provider_card_order(order: &[String]) -> Result<()> {
|
||||
if order.len() > 4096 {
|
||||
return Err(CodexxError::Config("供应商排序项过多".to_string()));
|
||||
}
|
||||
let mut seen = HashSet::with_capacity(order.len());
|
||||
for key in order {
|
||||
let valid_key = key.split_once(':').is_some_and(|(source, id)| {
|
||||
matches!(source, "official" | "local" | "detected")
|
||||
&& !id.trim().is_empty()
|
||||
&& id.trim() == id
|
||||
&& key.len() <= 512
|
||||
&& !key.chars().any(char::is_control)
|
||||
});
|
||||
if !valid_key {
|
||||
return Err(CodexxError::Config(
|
||||
"供应商排序包含无效卡片标识".to_string(),
|
||||
));
|
||||
}
|
||||
if !seen.insert(key) {
|
||||
return Err(CodexxError::Config("供应商排序包含重复卡片".to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_provider_order_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &std::path::Path,
|
||||
) -> Result<Vec<String>> {
|
||||
let json: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT order_json FROM provider_card_orders WHERE codex_dir = ?1",
|
||||
[normalized_path_scope(codex_dir)],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
json.map(|json| {
|
||||
serde_json::from_str(&json)
|
||||
.map_err(|error| CodexxError::Database(format!("读取供应商排序失败: {error}")))
|
||||
})
|
||||
.transpose()
|
||||
.map(Option::unwrap_or_default)
|
||||
}
|
||||
|
||||
pub(crate) fn get_provider_order_inner(config_dir: Option<String>) -> Result<Vec<String>> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
get_provider_order_on_connection(&open_db()?, &codex_dir)
|
||||
}
|
||||
|
||||
fn save_provider_order_on_connection(
|
||||
conn: &mut Connection,
|
||||
codex_dir: &std::path::Path,
|
||||
order: Vec<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
validate_provider_card_order(&order)?;
|
||||
let json = serde_json::to_string(&order)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化供应商排序失败: {error}")))?;
|
||||
let transaction = conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
// Keep the preference independent of provider records. A refresh, import,
|
||||
// edit, or temporary detected row must not change the user's chosen order;
|
||||
// the UI ignores absent keys and appends newly added cards.
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO provider_card_orders (codex_dir, order_json, updated_at)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(codex_dir) DO UPDATE SET
|
||||
order_json = excluded.order_json,
|
||||
updated_at = excluded.updated_at",
|
||||
params![normalized_path_scope(codex_dir), json, now_rfc3339()],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
Ok(order)
|
||||
}
|
||||
|
||||
pub(crate) fn save_provider_order_inner(
|
||||
config_dir: Option<String>,
|
||||
order: Vec<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
save_provider_order_on_connection(&mut open_db()?, &codex_dir, order)
|
||||
}
|
||||
|
||||
pub(crate) fn provider_by_id_on_connection(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
@@ -1235,6 +1324,175 @@ mod tests {
|
||||
conn
|
||||
}
|
||||
|
||||
fn provider_order_test_connection() -> Connection {
|
||||
let conn = test_connection();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE provider_card_orders (
|
||||
codex_dir TEXT PRIMARY KEY, order_json TEXT NOT NULL, updated_at TEXT NOT NULL);",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_card_order_persists_across_connections_and_keeps_config_scopes_independent() {
|
||||
let database_file = tempfile::NamedTempFile::new().unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let other_directory = tempfile::tempdir().unwrap();
|
||||
let mut conn = Connection::open(database_file.path()).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE provider_card_orders (
|
||||
codex_dir TEXT PRIMARY KEY, order_json TEXT NOT NULL, updated_at TEXT NOT NULL);",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(get_provider_order_on_connection(&conn, directory.path())
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
let chosen = vec![
|
||||
"local:shared-id".to_string(),
|
||||
"official:shared-id".to_string(),
|
||||
"detected:detected-自定义".to_string(),
|
||||
"official:openai-official".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
save_provider_order_on_connection(&mut conn, directory.path(), chosen.clone()).unwrap(),
|
||||
chosen
|
||||
);
|
||||
let second = vec!["official:another-account".to_string()];
|
||||
save_provider_order_on_connection(&mut conn, other_directory.path(), second.clone())
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
let reopened = Connection::open(database_file.path()).unwrap();
|
||||
assert_eq!(
|
||||
get_provider_order_on_connection(&reopened, directory.path()).unwrap(),
|
||||
chosen
|
||||
);
|
||||
assert_eq!(
|
||||
get_provider_order_on_connection(&reopened, other_directory.path()).unwrap(),
|
||||
second
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn provider_card_order_resolves_linked_config_directory_to_the_same_scope() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let alias_root = tempfile::tempdir().unwrap();
|
||||
let alias = alias_root.path().join("linked-config");
|
||||
std::os::unix::fs::symlink(directory.path(), &alias).unwrap();
|
||||
let mut conn = provider_order_test_connection();
|
||||
let order = vec!["local:linked-provider".to_string()];
|
||||
save_provider_order_on_connection(&mut conn, &alias, order.clone()).unwrap();
|
||||
assert_eq!(
|
||||
get_provider_order_on_connection(&conn, directory.path()).unwrap(),
|
||||
order
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_card_order_survives_provider_refresh_edits_additions_and_deletions() {
|
||||
let mut conn = provider_order_test_connection();
|
||||
let directory = std::path::Path::new("/fixture/provider-order-lifecycle");
|
||||
let original = provider("ordered-provider", "Imported", Some("fixture-key"));
|
||||
upsert_ccswitch_provider_on_connection(&conn, original.clone(), "ordered-source").unwrap();
|
||||
let order = vec![
|
||||
"local:ordered-provider".to_string(),
|
||||
"official:openai-official".to_string(),
|
||||
"detected:detected-temporary".to_string(),
|
||||
];
|
||||
save_provider_order_on_connection(&mut conn, directory, order.clone()).unwrap();
|
||||
assert_eq!(
|
||||
provider_by_id_on_connection(&conn, &original.id).unwrap(),
|
||||
Some(original)
|
||||
);
|
||||
|
||||
let fresh = provider("external-id", "Refreshed import", Some("fixture-key"));
|
||||
let refreshed = upsert_ccswitch_provider_on_connection(&conn, fresh, "ordered-source")
|
||||
.unwrap()
|
||||
.provider;
|
||||
assert_eq!(refreshed.id, "ordered-provider");
|
||||
let mut edited = refreshed;
|
||||
edited.model = "other-model".to_string();
|
||||
save_manual_provider_on_connection(&conn, edited).unwrap();
|
||||
save_manual_provider_on_connection(
|
||||
&conn,
|
||||
provider("new-provider", "New provider", Some("new-fixture-key")),
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("DELETE FROM providers WHERE id = 'ordered-provider'", [])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
get_provider_order_on_connection(&conn, directory).unwrap(),
|
||||
order
|
||||
);
|
||||
save_provider_order_on_connection(&mut conn, directory, Vec::new()).unwrap();
|
||||
assert!(get_provider_order_on_connection(&conn, directory)
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
assert!(provider_by_id_on_connection(&conn, "new-provider")
|
||||
.unwrap()
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_card_order_rejects_invalid_or_duplicate_keys_without_overwriting_saved_order() {
|
||||
let mut conn = provider_order_test_connection();
|
||||
let directory = std::path::Path::new("/fixture/provider-order-validation");
|
||||
let order = vec!["local:kept-provider".to_string()];
|
||||
save_provider_order_on_connection(&mut conn, directory, order.clone()).unwrap();
|
||||
for invalid in [
|
||||
vec![
|
||||
"local:kept-provider".to_string(),
|
||||
"local:kept-provider".to_string(),
|
||||
],
|
||||
vec!["unknown:provider".to_string()],
|
||||
vec!["local:".to_string()],
|
||||
vec!["official: account".to_string()],
|
||||
vec!["detected:invalid\nprovider".to_string()],
|
||||
vec![format!("local:{}", "x".repeat(512))],
|
||||
(0..4097).map(|id| format!("local:{id}")).collect(),
|
||||
] {
|
||||
assert!(save_provider_order_on_connection(&mut conn, directory, invalid).is_err());
|
||||
assert_eq!(
|
||||
get_provider_order_on_connection(&conn, directory).unwrap(),
|
||||
order
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_card_order_database_failure_rolls_back_the_entire_order() {
|
||||
let mut conn = provider_order_test_connection();
|
||||
let directory = std::path::Path::new("/fixture/provider-order-rollback");
|
||||
let before = vec![
|
||||
"official:openai-official".to_string(),
|
||||
"local:kept".to_string(),
|
||||
];
|
||||
save_provider_order_on_connection(&mut conn, directory, before.clone()).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TRIGGER fail_provider_card_order BEFORE UPDATE ON provider_card_orders
|
||||
BEGIN SELECT RAISE(ABORT, 'fixture order persistence failure'); END;",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(save_provider_order_on_connection(
|
||||
&mut conn,
|
||||
directory,
|
||||
vec![
|
||||
"local:kept".to_string(),
|
||||
"official:openai-official".to_string()
|
||||
],
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
get_provider_order_on_connection(&conn, directory).unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(
|
||||
conn.is_autocommit(),
|
||||
"failed save must release its transaction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_mappings_roundtrip_and_copy_preserve_independent_records() {
|
||||
let mut conn = copy_test_connection();
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use super::storage::{
|
||||
source_text_is_subagent, sqlite_subagent_thread_ids, thread_source_is_internal,
|
||||
};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::sqlite_utils::{sqlite_has_table, table_column_set};
|
||||
use rusqlite::{params_from_iter, types::Value as SqlValue, Connection, OpenFlags};
|
||||
@@ -81,6 +84,59 @@ fn timestamp_expr(columns: &HashSet<String>, ms_column: &str, seconds_column: &s
|
||||
}
|
||||
}
|
||||
|
||||
// Catalog visibility is independent of the thread index. A stale or incomplete
|
||||
// index must never turn a source-marked internal catalog row into a user thread.
|
||||
pub(super) fn catalog_internal_thread_ids(
|
||||
conn: &Connection,
|
||||
columns: &HashSet<String>,
|
||||
) -> Result<HashSet<String>> {
|
||||
if !columns.contains("thread_id") {
|
||||
return Ok(HashSet::new());
|
||||
}
|
||||
let source = text_expr(columns, "source_kind", "NULL");
|
||||
let thread_source = text_expr(columns, "thread_source", "NULL");
|
||||
let mut statement = conn
|
||||
.prepare(&format!(
|
||||
"SELECT thread_id, {source}, {thread_source} FROM local_thread_catalog"
|
||||
))
|
||||
.map_err(database_error)?;
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(database_error)?;
|
||||
let mut ids = HashSet::new();
|
||||
for row in rows {
|
||||
let (id, source, thread_source) = row.map_err(database_error)?;
|
||||
if source.as_deref().is_some_and(source_text_is_subagent)
|
||||
|| thread_source
|
||||
.as_deref()
|
||||
.is_some_and(thread_source_is_internal)
|
||||
{
|
||||
ids.insert(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
pub(super) fn scan_catalog_internal_thread_ids(paths: &[PathBuf]) -> Result<HashSet<String>> {
|
||||
let mut ids = HashSet::new();
|
||||
for path in paths {
|
||||
let conn = Connection::open_with_flags(
|
||||
path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(database_error)?;
|
||||
let columns = catalog_columns(&conn)?;
|
||||
ids.extend(catalog_internal_thread_ids(&conn, &columns)?);
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn collect_catalog_repair_threads(
|
||||
paths: &[PathBuf],
|
||||
target_provider: &str,
|
||||
@@ -100,6 +156,7 @@ fn collect_catalog_repair_threads(
|
||||
if !columns.contains("id") {
|
||||
continue;
|
||||
}
|
||||
let internal_ids = sqlite_subagent_thread_ids(&conn, &columns)?;
|
||||
let display_title = coalesce_text_expr(
|
||||
&columns,
|
||||
&["name", "title", "preview", "first_user_message"],
|
||||
@@ -150,7 +207,7 @@ fn collect_catalog_repair_threads(
|
||||
.map_err(database_error)?;
|
||||
for row in rows {
|
||||
let thread = row.map_err(database_error)?;
|
||||
if !syncable_thread_ids.contains(&thread.id) {
|
||||
if !syncable_thread_ids.contains(&thread.id) || internal_ids.contains(&thread.id) {
|
||||
continue;
|
||||
}
|
||||
let replace = threads
|
||||
@@ -238,8 +295,13 @@ pub(super) fn scan_catalog_sync(
|
||||
target_provider: &str,
|
||||
syncable_thread_ids: &HashSet<String>,
|
||||
) -> Result<CatalogSyncScan> {
|
||||
let internal_ids = scan_catalog_internal_thread_ids(catalog_paths)?;
|
||||
let syncable_thread_ids: HashSet<String> = syncable_thread_ids
|
||||
.difference(&internal_ids)
|
||||
.cloned()
|
||||
.collect();
|
||||
let sources =
|
||||
collect_catalog_repair_threads(thread_paths, target_provider, syncable_thread_ids)?;
|
||||
collect_catalog_repair_threads(thread_paths, target_provider, &syncable_thread_ids)?;
|
||||
let mut scan = CatalogSyncScan {
|
||||
sources,
|
||||
..CatalogSyncScan::default()
|
||||
@@ -680,18 +742,35 @@ pub(super) fn apply_catalog_updates(
|
||||
sources: &HashMap<String, CatalogRepairThread>,
|
||||
provider_thread_ids: &HashSet<String>,
|
||||
) -> Result<CatalogUpdateCounts> {
|
||||
let internal_ids = catalog_internal_thread_ids(conn, columns)?;
|
||||
let provider_thread_ids: HashSet<String> = provider_thread_ids
|
||||
.difference(&internal_ids)
|
||||
.cloned()
|
||||
.collect();
|
||||
let sources = sources
|
||||
.iter()
|
||||
.filter(|(id, thread)| {
|
||||
provider_thread_ids.contains(*id)
|
||||
&& !source_text_is_subagent(&thread.source_kind)
|
||||
&& !thread
|
||||
.thread_source
|
||||
.as_deref()
|
||||
.is_some_and(thread_source_is_internal)
|
||||
})
|
||||
.map(|(id, thread)| (id.clone(), thread.clone()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut counts = CatalogUpdateCounts::default();
|
||||
let repair_host = if !sources.is_empty() && catalog_supports_repair(columns) {
|
||||
Some(local_catalog_host_id(conn)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
snapshot_catalog_provider_changes(conn, columns, target_provider, provider_thread_ids)?;
|
||||
snapshot_catalog_provider_changes(conn, columns, target_provider, &provider_thread_ids)?;
|
||||
if let Some(host_id) = repair_host.as_deref() {
|
||||
snapshot_hidden_catalog_sources(conn, columns, host_id, sources)?;
|
||||
snapshot_hidden_catalog_sources(conn, columns, host_id, &sources)?;
|
||||
}
|
||||
if columns.contains("model_provider") && columns.contains("thread_id") {
|
||||
for thread_id in provider_thread_ids {
|
||||
for thread_id in &provider_thread_ids {
|
||||
conn.execute(
|
||||
"UPDATE local_thread_catalog SET model_provider = ?1 \
|
||||
WHERE thread_id = ?2 AND COALESCE(model_provider, '') <> ?1",
|
||||
@@ -701,9 +780,9 @@ pub(super) fn apply_catalog_updates(
|
||||
}
|
||||
}
|
||||
if let Some(host_id) = repair_host.as_deref() {
|
||||
reactivate_catalog_sources(conn, columns, host_id, sources)?;
|
||||
reactivate_catalog_sources(conn, columns, host_id, &sources)?;
|
||||
let (inserted, observation_sequence, max_source_updated_at) =
|
||||
insert_missing_catalog_sources(conn, columns, host_id, sources)?;
|
||||
insert_missing_catalog_sources(conn, columns, host_id, &sources)?;
|
||||
counts.inserted_rows = inserted;
|
||||
if inserted > 0 {
|
||||
update_catalog_sync_state(conn, host_id, observation_sequence, max_source_updated_at)?;
|
||||
@@ -1114,4 +1193,30 @@ mod tests {
|
||||
("openai".to_string(), 1, 7)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn internal_catalog_rows_and_sources_ignore_even_explicit_sync_candidates() {
|
||||
let conn = Connection::open_in_memory().expect("open internal catalog fixture");
|
||||
create_catalog_schema(&conn);
|
||||
insert_catalog_row(&conn, "hidden-review", "openai", 1);
|
||||
conn.execute("UPDATE local_thread_catalog SET thread_source = 'guardian_review' WHERE thread_id = 'hidden-review'", [])
|
||||
.expect("mark internal catalog source");
|
||||
let mut internal_source = repair_source("missing-review", "custom");
|
||||
internal_source.source_kind = r#"{"internal":"guardian"}"#.to_string();
|
||||
internal_source.thread_source = None;
|
||||
let sources = HashMap::from([(internal_source.id.clone(), internal_source)]);
|
||||
let candidates = HashSet::from(["hidden-review".to_string(), "missing-review".to_string()]);
|
||||
let counts = apply_and_commit(&conn, "custom", &sources, &candidates);
|
||||
assert_eq!(counts.provider_rows, 0);
|
||||
assert_eq!(counts.inserted_rows, 0);
|
||||
assert_eq!(
|
||||
catalog_state(&conn, "hidden-review"),
|
||||
("openai".to_string(), 1, 7)
|
||||
);
|
||||
let rows: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM local_thread_catalog", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.expect("count preserved catalog");
|
||||
assert_eq!(rows, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
use super::app_server::delete_sessions_via_codex_app_server;
|
||||
use super::rollout_stream::{for_each_jsonl_record, record_matches_top_level_strings};
|
||||
use super::storage::{
|
||||
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
|
||||
is_canonical_rollout_storage_path, rollout_filename_matches_id, scan_rollouts,
|
||||
split_line_ending, sqlite_subagent_thread_ids, sqlite_thread_needs_alignment, SqliteDiscovery,
|
||||
sqlite_subagent_thread_ids, sqlite_thread_needs_alignment, SqliteDiscovery,
|
||||
SqliteThreadIndexState,
|
||||
};
|
||||
use super::sync::{acquire_session_maintenance_lock, session_sync_status_with_discovery};
|
||||
use super::types::SessionSyncStatus;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{io_err, write_text};
|
||||
use crate::file_io::io_err;
|
||||
use crate::resolve_codex_dir;
|
||||
use crate::sqlite_utils::{sql_select_column, sqlite_has_table, table_column_set};
|
||||
use rusqlite::{Connection, OpenFlags, TransactionBehavior};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[cfg(test)]
|
||||
use super::storage::hash_rollout_file;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -481,88 +486,447 @@ fn selected_rollout_paths(
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
const CLEANUP_IO_BUFFER_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct CleanupFileStamp {
|
||||
len: u64,
|
||||
modified: Option<SystemTime>,
|
||||
created: Option<SystemTime>,
|
||||
unix_identity: Option<(u64, u64)>,
|
||||
}
|
||||
|
||||
impl CleanupFileStamp {
|
||||
fn from_metadata(metadata: &fs::Metadata) -> Self {
|
||||
#[cfg(unix)]
|
||||
let unix_identity = {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
Some((metadata.dev(), metadata.ino()))
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let unix_identity = None;
|
||||
Self {
|
||||
len: metadata.len(),
|
||||
modified: metadata.modified().ok(),
|
||||
created: metadata.created().ok(),
|
||||
unix_identity,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CleanupHashingReader<'a> {
|
||||
source: &'a mut fs::File,
|
||||
backup: Option<&'a mut fs::File>,
|
||||
digest: &'a mut Sha256,
|
||||
consumed: u64,
|
||||
}
|
||||
|
||||
impl Read for CleanupHashingReader<'_> {
|
||||
fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
|
||||
let count = self.source.read(bytes)?;
|
||||
if let Some(backup) = self.backup.as_mut() {
|
||||
backup.write_all(&bytes[..count])?;
|
||||
}
|
||||
self.digest.update(&bytes[..count]);
|
||||
self.consumed += count as u64;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_cleanup_bytes(reader: &mut impl Read, writer: &mut impl Write) -> std::io::Result<u64> {
|
||||
let mut bytes = [0u8; CLEANUP_IO_BUFFER_BYTES];
|
||||
let mut copied = 0u64;
|
||||
loop {
|
||||
let count = reader.read(&mut bytes)?;
|
||||
if count == 0 {
|
||||
return Ok(copied);
|
||||
}
|
||||
writer.write_all(&bytes[..count])?;
|
||||
copied += count as u64;
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_cleanup_records<R: Read, W: Write>(
|
||||
reader: R,
|
||||
writer: &mut W,
|
||||
path: &Path,
|
||||
id_keys: &[&str],
|
||||
session_ids: &HashSet<String>,
|
||||
) -> Result<usize> {
|
||||
let mut removed = 0usize;
|
||||
for_each_jsonl_record(reader, |record, _| {
|
||||
record
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
let matches = if session_ids.is_empty() {
|
||||
false
|
||||
} else {
|
||||
match record_matches_top_level_strings(record, id_keys, session_ids, 128) {
|
||||
Ok(matches) => matches,
|
||||
Err(error @ CodexxError::Io { .. }) => return Err(error),
|
||||
// Invalid JSON is preserved exactly as in the previous filter.
|
||||
Err(_) => false,
|
||||
}
|
||||
};
|
||||
if matches {
|
||||
removed += 1;
|
||||
} else {
|
||||
record
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
copy_cleanup_bytes(record, writer).map_err(|error| io_err(path, error))?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
struct PreparedJsonlCleanup {
|
||||
output: NamedTempFile,
|
||||
original_backup: Option<NamedTempFile>,
|
||||
original_stamp: CleanupFileStamp,
|
||||
original_hash: [u8; 32],
|
||||
removed: usize,
|
||||
}
|
||||
|
||||
fn cleanup_source_changed(path: &Path) -> CodexxError {
|
||||
CodexxError::Config(format!(
|
||||
"会话索引或历史记录在清理期间发生变化,已取消替换: {}",
|
||||
path.display()
|
||||
))
|
||||
}
|
||||
|
||||
fn hash_open_cleanup_file(path: &Path, source: &fs::File) -> Result<[u8; 32]> {
|
||||
// Read the existing handle: an exclusive history lock can reject a second
|
||||
// handle's read on Windows, even when it is opened by this same process.
|
||||
let mut reader = source;
|
||||
reader
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0u8; CLEANUP_IO_BUFFER_BYTES];
|
||||
loop {
|
||||
let count = reader
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
if count == 0 {
|
||||
return Ok(digest.finalize().into());
|
||||
}
|
||||
digest.update(&buffer[..count]);
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_cleanup_source_unchanged(
|
||||
path: &Path,
|
||||
source: &fs::File,
|
||||
stamp: &CleanupFileStamp,
|
||||
hash: &[u8; 32],
|
||||
) -> Result<()> {
|
||||
let source_stamp = || -> Result<CleanupFileStamp> {
|
||||
source
|
||||
.metadata()
|
||||
.map(|metadata| CleanupFileStamp::from_metadata(&metadata))
|
||||
.map_err(|error| io_err(path, error))
|
||||
};
|
||||
let path_stamp = || -> Result<CleanupFileStamp> {
|
||||
fs::metadata(path)
|
||||
.map(|metadata| CleanupFileStamp::from_metadata(&metadata))
|
||||
.map_err(|error| io_err(path, error))
|
||||
};
|
||||
if &source_stamp()? != stamp || &path_stamp()? != stamp {
|
||||
return Err(cleanup_source_changed(path));
|
||||
}
|
||||
if &hash_open_cleanup_file(path, source)? != hash
|
||||
|| &source_stamp()? != stamp
|
||||
|| &path_stamp()? != stamp
|
||||
{
|
||||
return Err(cleanup_source_changed(path));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_jsonl_cleanup(
|
||||
path: &Path,
|
||||
source: &mut fs::File,
|
||||
id_keys: &[&str],
|
||||
session_ids: &HashSet<String>,
|
||||
retain_backup: bool,
|
||||
) -> Result<PreparedJsonlCleanup> {
|
||||
let metadata = source.metadata().map_err(|error| io_err(path, error))?;
|
||||
if !metadata.is_file() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话日志不是普通文件: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let original_stamp = CleanupFileStamp::from_metadata(&metadata);
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let mut output = NamedTempFile::new_in(parent).map_err(|error| io_err(path, error))?;
|
||||
let mut original_backup = if retain_backup {
|
||||
Some(NamedTempFile::new_in(parent).map_err(|error| io_err(path, error))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
source
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut reader = CleanupHashingReader {
|
||||
source: &mut *source,
|
||||
backup: original_backup.as_mut().map(NamedTempFile::as_file_mut),
|
||||
digest: &mut digest,
|
||||
consumed: 0,
|
||||
};
|
||||
let removed = filter_cleanup_records(
|
||||
&mut reader,
|
||||
output.as_file_mut(),
|
||||
path,
|
||||
id_keys,
|
||||
session_ids,
|
||||
)?;
|
||||
let consumed = reader.consumed;
|
||||
drop(reader);
|
||||
if consumed != original_stamp.len
|
||||
|| CleanupFileStamp::from_metadata(&source.metadata().map_err(|error| io_err(path, error))?)
|
||||
!= original_stamp
|
||||
{
|
||||
return Err(cleanup_source_changed(path));
|
||||
}
|
||||
output
|
||||
.as_file()
|
||||
.sync_all()
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
if let Some(backup) = original_backup.as_ref() {
|
||||
backup
|
||||
.as_file()
|
||||
.sync_all()
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
}
|
||||
let original_hash = digest.finalize().into();
|
||||
ensure_cleanup_source_unchanged(path, source, &original_stamp, &original_hash)?;
|
||||
Ok(PreparedJsonlCleanup {
|
||||
output,
|
||||
original_backup,
|
||||
original_stamp,
|
||||
original_hash,
|
||||
removed,
|
||||
})
|
||||
}
|
||||
|
||||
fn preflight_session_jsonl_cleanup(codex_dir: &Path) -> Result<()> {
|
||||
// Framing and staging use fixed memory even for a very large valid history.
|
||||
// Read/write failures still occur before the irreversible deletion API.
|
||||
for filename in ["session_index.jsonl", "history.jsonl"] {
|
||||
let path = codex_dir.join(filename);
|
||||
let mut source = match fs::File::open(&path) {
|
||||
Ok(source) => source,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(io_err(&path, error)),
|
||||
};
|
||||
prepare_jsonl_cleanup(&path, &mut source, &[], &HashSet::new(), false)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn commit_index_cleanup(
|
||||
path: &Path,
|
||||
source: &fs::File,
|
||||
prepared: PreparedJsonlCleanup,
|
||||
) -> Result<usize> {
|
||||
if prepared.removed == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
ensure_cleanup_source_unchanged(
|
||||
path,
|
||||
source,
|
||||
&prepared.original_stamp,
|
||||
&prepared.original_hash,
|
||||
)?;
|
||||
let permissions = source
|
||||
.metadata()
|
||||
.map_err(|error| io_err(path, error))?
|
||||
.permissions();
|
||||
prepared
|
||||
.output
|
||||
.as_file()
|
||||
.set_permissions(permissions)
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
prepared
|
||||
.output
|
||||
.persist(path)
|
||||
.map_err(|error| io_err(path, error.error))?;
|
||||
Ok(prepared.removed)
|
||||
}
|
||||
|
||||
fn remove_jsonl_session_entries(
|
||||
path: &Path,
|
||||
id_keys: &[&str],
|
||||
session_ids: &HashSet<String>,
|
||||
) -> Result<usize> {
|
||||
if !path.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let text = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
|
||||
let (next, removed) = filter_jsonl_session_entries(&text, id_keys, session_ids);
|
||||
if removed > 0 {
|
||||
write_text(path, &next)?;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
fn filter_jsonl_session_entries(
|
||||
text: &str,
|
||||
id_keys: &[&str],
|
||||
session_ids: &HashSet<String>,
|
||||
) -> (String, usize) {
|
||||
let mut next = String::with_capacity(text.len());
|
||||
let mut removed = 0usize;
|
||||
for segment in text.split_inclusive('\n') {
|
||||
let (line, ending) = split_line_ending(segment);
|
||||
let matches = serde_json::from_str::<Value>(line)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
id_keys.iter().find_map(|key| {
|
||||
value
|
||||
.get(*key)
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
})
|
||||
.is_some_and(|id| session_ids.contains(&id));
|
||||
if matches {
|
||||
removed += 1;
|
||||
} else {
|
||||
next.push_str(line);
|
||||
next.push_str(ending);
|
||||
}
|
||||
}
|
||||
(next, removed)
|
||||
let mut source = match fs::File::open(path) {
|
||||
Ok(source) => source,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
|
||||
Err(error) => return Err(io_err(path, error)),
|
||||
};
|
||||
let prepared = prepare_jsonl_cleanup(path, &mut source, id_keys, session_ids, false)?;
|
||||
commit_index_cleanup(path, &source, prepared)
|
||||
}
|
||||
|
||||
fn remove_session_index_entries(codex_dir: &Path, session_ids: &HashSet<String>) -> Result<usize> {
|
||||
remove_jsonl_session_entries(&codex_dir.join("session_index.jsonl"), &["id"], session_ids)
|
||||
}
|
||||
|
||||
fn restore_history_backup(
|
||||
path: &Path,
|
||||
source: &mut fs::File,
|
||||
backup: &mut fs::File,
|
||||
original_len: u64,
|
||||
original_hash: &[u8; 32],
|
||||
) -> Result<()> {
|
||||
backup
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
source
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
if copy_cleanup_bytes(backup, source).map_err(|error| io_err(path, error))? != original_len {
|
||||
return Err(CodexxError::Config(
|
||||
"历史记录备份不完整,无法安全回滚".into(),
|
||||
));
|
||||
}
|
||||
// Filtering never grows the file. Replaying the complete backup restores its
|
||||
// original length after a failed truncate, and does not truncate a later
|
||||
// append beyond the original EOF from a writer that ignored the lock.
|
||||
source.sync_all().map_err(|error| io_err(path, error))?;
|
||||
source
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0u8; CLEANUP_IO_BUFFER_BYTES];
|
||||
let mut remaining = original_len;
|
||||
while remaining > 0 {
|
||||
let allowed = buffer.len().min(remaining as usize);
|
||||
let count = source
|
||||
.read(&mut buffer[..allowed])
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
if count == 0 {
|
||||
return Err(CodexxError::Config("历史记录回滚后长度不足".into()));
|
||||
}
|
||||
digest.update(&buffer[..count]);
|
||||
remaining -= count as u64;
|
||||
}
|
||||
let restored: [u8; 32] = digest.finalize().into();
|
||||
if &restored != original_hash {
|
||||
return Err(CodexxError::Config("历史记录回滚校验失败".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn commit_history_cleanup_with_writer<F>(
|
||||
path: &Path,
|
||||
source: &mut fs::File,
|
||||
mut prepared: PreparedJsonlCleanup,
|
||||
write_output: F,
|
||||
) -> Result<usize>
|
||||
where
|
||||
F: FnOnce(&mut fs::File, &mut fs::File, u64, u64) -> Result<()>,
|
||||
{
|
||||
if prepared.removed == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
ensure_cleanup_source_unchanged(
|
||||
path,
|
||||
source,
|
||||
&prepared.original_stamp,
|
||||
&prepared.original_hash,
|
||||
)?;
|
||||
let output_len = prepared
|
||||
.output
|
||||
.as_file()
|
||||
.metadata()
|
||||
.map_err(|error| io_err(path, error))?
|
||||
.len();
|
||||
if output_len > prepared.original_stamp.len {
|
||||
return Err(CodexxError::Config(
|
||||
"历史记录过滤结果异常,未写入原文件".into(),
|
||||
));
|
||||
}
|
||||
let mut backup = prepared
|
||||
.original_backup
|
||||
.take()
|
||||
.ok_or_else(|| CodexxError::Config("历史记录缺少完整磁盘备份,未写入原文件".into()))?;
|
||||
if let Err(error) = write_output(
|
||||
source,
|
||||
prepared.output.as_file_mut(),
|
||||
output_len,
|
||||
prepared.original_stamp.len,
|
||||
) {
|
||||
if let Err(restore_error) = restore_history_backup(
|
||||
path,
|
||||
source,
|
||||
backup.as_file_mut(),
|
||||
prepared.original_stamp.len,
|
||||
&prepared.original_hash,
|
||||
) {
|
||||
return match backup.keep() {
|
||||
Ok((_, retained)) => Err(CodexxError::Config(format!(
|
||||
"{error};历史记录回滚失败:{restore_error};已保留私有备份:{}",
|
||||
retained.display()
|
||||
))),
|
||||
Err(keep_error) => Err(CodexxError::Config(format!(
|
||||
"{error};历史记录回滚失败:{restore_error};保留备份失败:{keep_error}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(prepared.removed)
|
||||
}
|
||||
|
||||
fn remove_session_history_entries(
|
||||
codex_dir: &Path,
|
||||
session_ids: &HashSet<String>,
|
||||
) -> Result<usize> {
|
||||
let path = codex_dir.join("history.jsonl");
|
||||
if !path.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.map_err(|e| io_err(&path, e))?;
|
||||
let mut file = match fs::OpenOptions::new().read(true).write(true).open(&path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
|
||||
Err(error) => return Err(io_err(&path, error)),
|
||||
};
|
||||
file.try_lock().map_err(|error| {
|
||||
CodexxError::Config(format!(
|
||||
"历史记录正在被其他 Codex 进程使用,请关闭相关 Codex 窗口或 CLI 后重试: {error}"
|
||||
))
|
||||
})?;
|
||||
let result = (|| -> Result<usize> {
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text)
|
||||
.map_err(|e| io_err(&path, e))?;
|
||||
let (next, removed) = filter_jsonl_session_entries(&text, &["session_id"], session_ids);
|
||||
if removed > 0 {
|
||||
file.set_len(0).map_err(|e| io_err(&path, e))?;
|
||||
file.seek(SeekFrom::Start(0))
|
||||
.map_err(|e| io_err(&path, e))?;
|
||||
file.write_all(next.as_bytes())
|
||||
.map_err(|e| io_err(&path, e))?;
|
||||
file.sync_all().map_err(|e| io_err(&path, e))?;
|
||||
}
|
||||
Ok(removed)
|
||||
let prepared = prepare_jsonl_cleanup(&path, &mut file, &["session_id"], session_ids, true)?;
|
||||
commit_history_cleanup_with_writer(
|
||||
&path,
|
||||
&mut file,
|
||||
prepared,
|
||||
|source, output, output_len, original_len| {
|
||||
output
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(&path, error))?;
|
||||
source
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(&path, error))?;
|
||||
copy_cleanup_bytes(output, source).map_err(|error| io_err(&path, error))?;
|
||||
if source
|
||||
.metadata()
|
||||
.map_err(|error| io_err(&path, error))?
|
||||
.len()
|
||||
!= original_len
|
||||
{
|
||||
return Err(cleanup_source_changed(&path));
|
||||
}
|
||||
source
|
||||
.set_len(output_len)
|
||||
.map_err(|error| io_err(&path, error))?;
|
||||
source.sync_all().map_err(|error| io_err(&path, error))?;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
})();
|
||||
let _ = file.unlock();
|
||||
result
|
||||
@@ -787,6 +1151,7 @@ pub(crate) fn hard_delete_sessions_locally(
|
||||
let relationship_sources = relationship_database_sources(&discovery, roots)?;
|
||||
let session_ids = session_ids_with_descendants(&relationship_sources, roots)?;
|
||||
let rollout_paths = selected_rollout_paths(codex_dir, &discovery.thread_paths, &session_ids)?;
|
||||
preflight_session_jsonl_cleanup(codex_dir)?;
|
||||
Ok(delete_exact_session_ids_locally(
|
||||
codex_dir,
|
||||
&discovery.related_paths,
|
||||
@@ -834,6 +1199,7 @@ pub(crate) fn delete_codex_sessions_inner(
|
||||
// make the deletion irreversible.
|
||||
let expected_rollout_paths =
|
||||
selected_rollout_paths(&codex_dir, &discovery.thread_paths, &expected_ids)?;
|
||||
preflight_session_jsonl_cleanup(&codex_dir)?;
|
||||
let mut counts = LocalSessionDeleteCounts::default();
|
||||
let mut failed_roots = Vec::new();
|
||||
|
||||
@@ -1121,4 +1487,417 @@ mod tests {
|
||||
|
||||
let _ = fs::remove_dir_all(codex_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_cleanup_logs_cancel_native_and_local_deletion_before_any_mutation() {
|
||||
for filename in ["session_index.jsonl", "history.jsonl"] {
|
||||
let codex_dir = temp_codex_dir();
|
||||
let id = "019f6000-0000-7000-8000-000000000371";
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, id);
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
fs::create_dir_all(rollout.parent().unwrap()).unwrap();
|
||||
let rollout_text = format!(
|
||||
"{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"model_provider\":\"openai\"}}}}\n"
|
||||
);
|
||||
fs::write(&rollout, &rollout_text).unwrap();
|
||||
for (name, key) in [
|
||||
("session_index.jsonl", "id"),
|
||||
("history.jsonl", "session_id"),
|
||||
] {
|
||||
fs::write(codex_dir.join(name), format!("{{\"{key}\":\"{id}\"}}\n")).unwrap();
|
||||
}
|
||||
let unreadable = codex_dir.join(filename);
|
||||
fs::remove_file(&unreadable).unwrap();
|
||||
fs::create_dir(&unreadable).unwrap();
|
||||
let database_before = fs::read(&database).unwrap();
|
||||
let untouched_name = if filename == "history.jsonl" {
|
||||
"session_index.jsonl"
|
||||
} else {
|
||||
"history.jsonl"
|
||||
};
|
||||
let untouched_path = codex_dir.join(untouched_name);
|
||||
let untouched_before = fs::read(&untouched_path).unwrap();
|
||||
|
||||
let local_error = hard_delete_sessions_locally(&codex_dir, &[id.to_string()])
|
||||
.expect_err("local deletion must reject an unreadable cleanup log");
|
||||
assert!(local_error.to_string().contains(filename));
|
||||
let native_error = delete_codex_sessions_inner(SessionDeleteInput {
|
||||
config_dir: Some(codex_dir.display().to_string()),
|
||||
session_ids: vec![id.to_string()],
|
||||
})
|
||||
.expect_err("native deletion must stop at preflight before starting Codex");
|
||||
assert!(native_error.to_string().contains(filename));
|
||||
|
||||
assert_eq!(fs::read(&rollout).unwrap(), rollout_text.as_bytes());
|
||||
assert_eq!(fs::read(&database).unwrap(), database_before);
|
||||
assert_eq!(fs::read(&untouched_path).unwrap(), untouched_before);
|
||||
assert!(unreadable.is_dir());
|
||||
let present =
|
||||
active_session_ids_present(&[database], &HashSet::from([id.to_string()])).unwrap();
|
||||
assert!(present.contains(id));
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_cleanup_logs_stream_without_a_total_size_limit() {
|
||||
for filename in ["session_index.jsonl", "history.jsonl"] {
|
||||
let codex_dir = temp_codex_dir();
|
||||
let id = "019f6000-0000-7000-8000-000000000381";
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, id);
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
fs::create_dir_all(rollout.parent().unwrap()).unwrap();
|
||||
fs::write(&rollout, format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"model_provider\":\"openai\"}}}}\n")).unwrap();
|
||||
let path = codex_dir.join(filename);
|
||||
let key = if filename == "history.jsonl" {
|
||||
"session_id"
|
||||
} else {
|
||||
"id"
|
||||
};
|
||||
let remove_large_record = filename == "history.jsonl";
|
||||
let mut file = fs::File::create(&path).unwrap();
|
||||
let large_id = if remove_large_record { id } else { "kept" };
|
||||
let prefix = format!("{{\"{key}\":\"{large_id}\",\"text\":\"");
|
||||
let mut expected = Sha256::new();
|
||||
file.write_all(prefix.as_bytes()).unwrap();
|
||||
if !remove_large_record {
|
||||
expected.update(prefix.as_bytes());
|
||||
}
|
||||
let bytes = [b'x'; CLEANUP_IO_BUFFER_BYTES];
|
||||
for _ in 0..(33 * 1024 * 1024 / CLEANUP_IO_BUFFER_BYTES) {
|
||||
file.write_all(&bytes).unwrap();
|
||||
if !remove_large_record {
|
||||
expected.update(bytes);
|
||||
}
|
||||
}
|
||||
file.write_all(b"\"}\r\n").unwrap();
|
||||
if !remove_large_record {
|
||||
expected.update(b"\"}\r\n");
|
||||
file.write_all(format!("{{\"{key}\":\"{id}\"}}\n").as_bytes())
|
||||
.unwrap();
|
||||
}
|
||||
let tail = format!("not-json\r\n{{\"{key}\":\"{id}\",\"{key}\":\"kept-last\"}}\n{{\"{key}\":\"kept-final\"}}");
|
||||
file.write_all(tail.as_bytes()).unwrap();
|
||||
expected.update(tail.as_bytes());
|
||||
drop(file);
|
||||
assert!(fs::metadata(&path).unwrap().len() > 32 * 1024 * 1024);
|
||||
let other_name = if filename == "history.jsonl" {
|
||||
"session_index.jsonl"
|
||||
} else {
|
||||
"history.jsonl"
|
||||
};
|
||||
let other_key = if key == "id" { "session_id" } else { "id" };
|
||||
fs::write(
|
||||
codex_dir.join(other_name),
|
||||
format!("{{\"{other_key}\":\"{id}\"}}\r\n{{\"{other_key}\":\"untouched\"}}"),
|
||||
)
|
||||
.unwrap();
|
||||
preflight_session_jsonl_cleanup(&codex_dir)
|
||||
.expect("large valid logs must pass streaming preflight");
|
||||
let result = hard_delete_sessions_locally(&codex_dir, &[id.to_string()]).unwrap();
|
||||
assert!(result.errors.is_empty(), "{:?}", result.errors);
|
||||
assert!(!rollout.exists());
|
||||
assert!(
|
||||
!active_session_ids_present(&[database], &HashSet::from([id.to_string()]))
|
||||
.unwrap()
|
||||
.contains(id)
|
||||
);
|
||||
let expected_hash: [u8; 32] = expected.finalize().into();
|
||||
assert_eq!(hash_rollout_file(&path).unwrap(), expected_hash);
|
||||
assert_eq!(
|
||||
fs::read(codex_dir.join(other_name)).unwrap(),
|
||||
format!("{{\"{other_key}\":\"untouched\"}}").as_bytes()
|
||||
);
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_cleanup_preserves_missing_files_and_normal_jsonl_filtering() {
|
||||
let codex_dir = temp_codex_dir();
|
||||
preflight_session_jsonl_cleanup(&codex_dir).unwrap();
|
||||
assert!(!codex_dir.join("session_index.jsonl").exists());
|
||||
assert!(!codex_dir.join("history.jsonl").exists());
|
||||
let ids = HashSet::from(["selected".to_string()]);
|
||||
for (filename, key) in [
|
||||
("session_index.jsonl", "id"),
|
||||
("history.jsonl", "session_id"),
|
||||
] {
|
||||
fs::write(
|
||||
codex_dir.join(filename),
|
||||
format!("{{\"{key}\":\"selected\"}}\r\nnot-json\r\n{{\"{key}\":\"kept\"}}"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
preflight_session_jsonl_cleanup(&codex_dir).unwrap();
|
||||
assert_eq!(remove_session_index_entries(&codex_dir, &ids).unwrap(), 1);
|
||||
assert_eq!(remove_session_history_entries(&codex_dir, &ids).unwrap(), 1);
|
||||
for (filename, key) in [
|
||||
("session_index.jsonl", "id"),
|
||||
("history.jsonl", "session_id"),
|
||||
] {
|
||||
assert_eq!(
|
||||
fs::read_to_string(codex_dir.join(filename)).unwrap(),
|
||||
format!("not-json\r\n{{\"{key}\":\"kept\"}}")
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_cleanup_only_matches_valid_top_level_ids_and_preserves_other_bytes() {
|
||||
let codex_dir = temp_codex_dir();
|
||||
let path = codex_dir.join("session_index.jsonl");
|
||||
let kept = b"not-json\r\n{\"nested\":{\"id\":\"selected\"},\"id\":\"kept\"}\n{\"id\":\"selected\",\"id\":null}\r\n\xff\n";
|
||||
let mut original = kept.to_vec();
|
||||
original.extend_from_slice(b"{\"id\":\"selected\"}\r\n");
|
||||
original.extend_from_slice(b"{\"id\":\"last-kept\"}");
|
||||
fs::write(&path, original).unwrap();
|
||||
assert_eq!(
|
||||
remove_session_index_entries(&codex_dir, &HashSet::from(["selected".to_string()]))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
let mut expected = kept.to_vec();
|
||||
expected.extend_from_slice(b"{\"id\":\"last-kept\"}");
|
||||
assert_eq!(fs::read(path).unwrap(), expected);
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
|
||||
struct FailingCleanupRead<R> {
|
||||
inner: R,
|
||||
remaining: usize,
|
||||
}
|
||||
impl<R: Read> Read for FailingCleanupRead<R> {
|
||||
fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
|
||||
if self.remaining == 0 {
|
||||
return Err(std::io::Error::other("injected cleanup read failure"));
|
||||
}
|
||||
let allowed = bytes.len().min(self.remaining);
|
||||
let count = self.inner.read(&mut bytes[..allowed])?;
|
||||
self.remaining -= count;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
struct FailingCleanupWrite<W> {
|
||||
inner: W,
|
||||
remaining: usize,
|
||||
}
|
||||
impl<W: Write> Write for FailingCleanupWrite<W> {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
if self.remaining == 0 {
|
||||
return Err(std::io::Error::other("injected cleanup write failure"));
|
||||
}
|
||||
let count = self
|
||||
.inner
|
||||
.write(&bytes[..bytes.len().min(self.remaining)])?;
|
||||
self.remaining -= count;
|
||||
Ok(count)
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_cleanup_read_or_staging_write_failure_preserves_the_source() {
|
||||
let codex_dir = temp_codex_dir();
|
||||
let path = codex_dir.join("session_index.jsonl");
|
||||
let original = b"{\"id\":\"kept\",\"text\":\"retained\"}\r\n{\"id\":\"selected\"}\n";
|
||||
fs::write(&path, original).unwrap();
|
||||
let ids = HashSet::from(["selected".to_string()]);
|
||||
let mut target = NamedTempFile::new_in(&codex_dir).unwrap();
|
||||
let error = filter_cleanup_records(
|
||||
FailingCleanupRead {
|
||||
inner: fs::File::open(&path).unwrap(),
|
||||
remaining: 7,
|
||||
},
|
||||
target.as_file_mut(),
|
||||
&path,
|
||||
&["id"],
|
||||
&ids,
|
||||
)
|
||||
.expect_err("read errors must propagate, not become a malformed record");
|
||||
assert!(matches!(error, CodexxError::Io { .. }));
|
||||
assert_eq!(fs::read(&path).unwrap(), original);
|
||||
let mut writer = FailingCleanupWrite {
|
||||
inner: target.as_file_mut(),
|
||||
remaining: 3,
|
||||
};
|
||||
let error = filter_cleanup_records(
|
||||
fs::File::open(&path).unwrap(),
|
||||
&mut writer,
|
||||
&path,
|
||||
&["id"],
|
||||
&ids,
|
||||
)
|
||||
.expect_err("a failed private output must never be installed");
|
||||
assert!(matches!(error, CodexxError::Io { .. }));
|
||||
assert_eq!(fs::read(&path).unwrap(), original);
|
||||
drop(target);
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_cleanup_detects_growth_before_index_or_history_commit() {
|
||||
for history in [false, true] {
|
||||
let codex_dir = temp_codex_dir();
|
||||
let path = codex_dir.join(if history {
|
||||
"history.jsonl"
|
||||
} else {
|
||||
"session_index.jsonl"
|
||||
});
|
||||
let key = if history { "session_id" } else { "id" };
|
||||
fs::write(
|
||||
&path,
|
||||
format!("{{\"{key}\":\"selected\"}}\n{{\"{key}\":\"kept\"}}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut source = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
let prepared = prepare_jsonl_cleanup(
|
||||
&path,
|
||||
&mut source,
|
||||
&[key],
|
||||
&HashSet::from(["selected".to_string()]),
|
||||
history,
|
||||
)
|
||||
.unwrap();
|
||||
let mut external = fs::OpenOptions::new().append(true).open(&path).unwrap();
|
||||
external
|
||||
.write_all(format!("{{\"{key}\":\"new-append\"}}\n").as_bytes())
|
||||
.unwrap();
|
||||
external.sync_all().unwrap();
|
||||
drop(external);
|
||||
let grown = fs::read(&path).unwrap();
|
||||
let result = if history {
|
||||
commit_history_cleanup_with_writer(&path, &mut source, prepared, |_, _, _, _| {
|
||||
panic!("growth must be rejected before writing history")
|
||||
})
|
||||
} else {
|
||||
commit_index_cleanup(&path, &source, prepared)
|
||||
};
|
||||
assert!(result
|
||||
.expect_err("a stale staged result must not replace appended data")
|
||||
.to_string()
|
||||
.contains("发生变化"));
|
||||
assert_eq!(fs::read(&path).unwrap(), grown);
|
||||
drop(source);
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_commit_write_failure_restores_backup_and_keeps_its_lock_and_inode() {
|
||||
for fail_after_truncate in [false, true] {
|
||||
let codex_dir = temp_codex_dir();
|
||||
let path = codex_dir.join("history.jsonl");
|
||||
let original = b"{\"session_id\":\"selected\",\"text\":\"private fixture\"}\r\n{\"session_id\":\"kept\"}";
|
||||
fs::write(&path, original).unwrap();
|
||||
let before = CleanupFileStamp::from_metadata(&fs::metadata(&path).unwrap());
|
||||
let mut source = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
source.try_lock().unwrap();
|
||||
let probe = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
assert!(probe.try_lock().is_err());
|
||||
let prepared = prepare_jsonl_cleanup(
|
||||
&path,
|
||||
&mut source,
|
||||
&["session_id"],
|
||||
&HashSet::from(["selected".to_string()]),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let error = commit_history_cleanup_with_writer(
|
||||
&path,
|
||||
&mut source,
|
||||
prepared,
|
||||
|source, output, output_len, _| {
|
||||
source.seek(SeekFrom::Start(0)).unwrap();
|
||||
output.seek(SeekFrom::Start(0)).unwrap();
|
||||
if fail_after_truncate {
|
||||
copy_cleanup_bytes(output, source).unwrap();
|
||||
source.set_len(output_len).unwrap();
|
||||
} else {
|
||||
source.write_all(b"partial failed write").unwrap();
|
||||
}
|
||||
Err(io_err(
|
||||
&path,
|
||||
std::io::Error::other("injected commit write/sync failure"),
|
||||
))
|
||||
},
|
||||
)
|
||||
.expect_err("failed commit must report the failure after restoring the disk backup");
|
||||
assert!(error.to_string().contains("injected commit"));
|
||||
let expected_hash: [u8; 32] = Sha256::digest(original).into();
|
||||
assert_eq!(
|
||||
hash_open_cleanup_file(&path, &source).unwrap(),
|
||||
expected_hash
|
||||
);
|
||||
assert_eq!(source.metadata().unwrap().len(), original.len() as u64);
|
||||
assert_eq!(
|
||||
CleanupFileStamp::from_metadata(&source.metadata().unwrap()).unix_identity,
|
||||
before.unix_identity
|
||||
);
|
||||
assert!(
|
||||
probe.try_lock().is_err(),
|
||||
"rollback must not release the live history lock"
|
||||
);
|
||||
source.unlock().unwrap();
|
||||
assert!(probe.try_lock().is_ok());
|
||||
probe.unlock().unwrap();
|
||||
drop(probe);
|
||||
drop(source);
|
||||
assert_eq!(fs::read(&path).unwrap(), original);
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_history_cleanup_keeps_an_already_open_append_handle_usable() {
|
||||
let codex_dir = temp_codex_dir();
|
||||
let path = codex_dir.join("history.jsonl");
|
||||
fs::write(
|
||||
&path,
|
||||
b"{\"session_id\":\"selected\"}\n{\"session_id\":\"kept\"}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let before = CleanupFileStamp::from_metadata(&fs::metadata(&path).unwrap());
|
||||
let mut old_append_handle = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
remove_session_history_entries(&codex_dir, &HashSet::from(["selected".to_string()]))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
old_append_handle.try_lock().unwrap();
|
||||
old_append_handle
|
||||
.write_all(b"{\"session_id\":\"later\"}\r\n")
|
||||
.unwrap();
|
||||
old_append_handle.unlock().unwrap();
|
||||
drop(old_append_handle);
|
||||
let after = CleanupFileStamp::from_metadata(&fs::metadata(&path).unwrap());
|
||||
assert_eq!(after.unix_identity, before.unix_identity);
|
||||
assert_eq!(
|
||||
fs::read(&path).unwrap(),
|
||||
b"{\"session_id\":\"kept\"}\n{\"session_id\":\"later\"}\r\n"
|
||||
);
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
use super::types::SessionPreview;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
#[cfg(unix)]
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct DirectoryIdentity {
|
||||
key: String,
|
||||
birth_verified: bool,
|
||||
}
|
||||
|
||||
fn filesystem_identity(
|
||||
platform: &[u8],
|
||||
parts: &[&[u8]],
|
||||
birth: Option<SystemTime>,
|
||||
) -> DirectoryIdentity {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"codex-x/session-directory-identity/v1\0");
|
||||
digest.update(platform);
|
||||
digest.update(b"\0");
|
||||
for part in parts {
|
||||
digest.update(part);
|
||||
}
|
||||
if let Some(birth) = birth {
|
||||
digest.update(b"\0birth\0");
|
||||
let (sign, age) = match birth.duration_since(UNIX_EPOCH) {
|
||||
Ok(age) => (b'+', age),
|
||||
Err(before_epoch) => (b'-', before_epoch.duration()),
|
||||
};
|
||||
digest.update([sign]);
|
||||
digest.update(age.as_secs().to_le_bytes());
|
||||
digest.update(age.subsec_nanos().to_le_bytes());
|
||||
} else {
|
||||
digest.update(b"\0no-birth\0");
|
||||
}
|
||||
DirectoryIdentity {
|
||||
key: format!("filesystem:{:x}", digest.finalize()),
|
||||
birth_verified: birth.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
fn unix_directory_identity(
|
||||
device: u64,
|
||||
inode: u64,
|
||||
birth: Option<SystemTime>,
|
||||
) -> Option<DirectoryIdentity> {
|
||||
// Some virtual or unsupported filesystems expose no usable inode number.
|
||||
if inode == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(filesystem_identity(
|
||||
b"unix",
|
||||
&[&device.to_le_bytes(), &inode.to_le_bytes()],
|
||||
birth,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn windows_directory_identity(
|
||||
volume: u64,
|
||||
identifier: &[u8; 16],
|
||||
birth: Option<SystemTime>,
|
||||
) -> Option<DirectoryIdentity> {
|
||||
// The complete 128-bit ID is needed for ReFS; the old 64-bit file index
|
||||
// cannot safely distinguish all directories on that filesystem.
|
||||
if identifier.iter().all(|byte| *byte == 0) {
|
||||
return None;
|
||||
}
|
||||
Some(filesystem_identity(
|
||||
b"windows",
|
||||
&[&volume.to_le_bytes(), identifier],
|
||||
birth,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn native_directory_identity(path: &Path) -> io::Result<DirectoryIdentity> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let handle = File::open(path)?;
|
||||
let metadata = handle.metadata()?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"not a directory",
|
||||
));
|
||||
}
|
||||
unix_directory_identity(metadata.dev(), metadata.ino(), metadata.created().ok())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, "directory has no stable inode"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn native_directory_identity(path: &Path) -> io::Result<DirectoryIdentity> {
|
||||
use std::fs::OpenOptions;
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
FileIdInfo, GetFileInformationByHandleEx, FILE_FLAG_BACKUP_SEMANTICS, FILE_ID_INFO,
|
||||
FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
|
||||
};
|
||||
|
||||
// READ_ATTRIBUTES plus BACKUP_SEMANTICS opens directories without requiring
|
||||
// permission to list them. No content is read and no filesystem state is changed.
|
||||
let handle = OpenOptions::new()
|
||||
.read(true)
|
||||
.access_mode(FILE_READ_ATTRIBUTES)
|
||||
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
|
||||
.open(path)?;
|
||||
let metadata = handle.metadata()?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"not a directory",
|
||||
));
|
||||
}
|
||||
let mut info = FILE_ID_INFO::default();
|
||||
// The owned read-only handle stays alive while Windows fills this exact
|
||||
// FILE_ID_INFO buffer. Unsupported filesystems return an error and fall back.
|
||||
let succeeded = unsafe {
|
||||
GetFileInformationByHandleEx(
|
||||
handle.as_raw_handle(),
|
||||
FileIdInfo,
|
||||
(&mut info as *mut FILE_ID_INFO).cast(),
|
||||
std::mem::size_of::<FILE_ID_INFO>() as u32,
|
||||
)
|
||||
};
|
||||
if succeeded == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
windows_directory_identity(
|
||||
info.VolumeSerialNumber,
|
||||
&info.FileId.Identifier,
|
||||
metadata.created().ok(),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"directory has no stable file ID",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn native_directory_identity(_path: &Path) -> io::Result<DirectoryIdentity> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"directory identity is unavailable",
|
||||
))
|
||||
}
|
||||
|
||||
fn unresolved_directory_identity(path: &str) -> DirectoryIdentity {
|
||||
DirectoryIdentity {
|
||||
key: format!("path:{path}"),
|
||||
birth_verified: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn directory_lookup(path: &str) -> DirectoryIdentity {
|
||||
let filesystem_path = Path::new(path);
|
||||
// Historical relative cwd values cannot be resolved against this app's
|
||||
// process cwd. Check the path kind before opening, including FIFO/device
|
||||
// values that must never be treated as a workspace directory.
|
||||
if !filesystem_path.is_absolute()
|
||||
|| !std::fs::metadata(filesystem_path).is_ok_and(|metadata| metadata.is_dir())
|
||||
{
|
||||
return unresolved_directory_identity(path);
|
||||
}
|
||||
native_directory_identity(filesystem_path)
|
||||
.unwrap_or_else(|_| unresolved_directory_identity(path))
|
||||
}
|
||||
|
||||
fn directory_identity(path: &str) -> String {
|
||||
directory_lookup(path).key
|
||||
}
|
||||
|
||||
fn pin_scope_key(path: &str, identity: &DirectoryIdentity) -> String {
|
||||
if identity.birth_verified {
|
||||
identity.key.clone()
|
||||
} else {
|
||||
format!("path:{path}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the paths represented by this status only. The raw spelling remains
|
||||
/// the map key. Persistent home scope uses native identity only when birth time
|
||||
/// was verified; otherwise it conservatively follows the original home path.
|
||||
pub(super) fn session_directory_identities(
|
||||
codex_dir: &Path,
|
||||
sessions: &[SessionPreview],
|
||||
) -> (HashMap<String, String>, String) {
|
||||
let mut identities = HashMap::new();
|
||||
let home = codex_dir.display().to_string();
|
||||
let home_identity = directory_lookup(&home);
|
||||
let pin_scope = pin_scope_key(&home, &home_identity);
|
||||
identities.insert(home.clone(), home_identity.key);
|
||||
for path in sessions.iter().filter_map(|session| session.cwd.as_deref()) {
|
||||
identities
|
||||
.entry(path.to_string())
|
||||
.or_insert_with(|| directory_identity(path));
|
||||
}
|
||||
(identities, pin_scope)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct TestDirectory {
|
||||
path: PathBuf,
|
||||
_directory: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl TestDirectory {
|
||||
fn new() -> Self {
|
||||
let mut builder = tempfile::Builder::new();
|
||||
builder.prefix("codex-x-directory-identity-");
|
||||
let directory = match std::env::var_os("CODEXX_TEST_CASE_DIRECTORY_ROOT") {
|
||||
Some(root) => builder.tempdir_in(root),
|
||||
None => builder.tempdir(),
|
||||
}
|
||||
.expect("create temporary directory identity fixture");
|
||||
Self {
|
||||
path: directory.path().to_path_buf(),
|
||||
_directory: directory,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn preview(id: &str, cwd: Option<String>) -> SessionPreview {
|
||||
SessionPreview {
|
||||
id: id.to_string(),
|
||||
title: id.to_string(),
|
||||
cwd,
|
||||
model_provider: None,
|
||||
model: None,
|
||||
rollout_path: None,
|
||||
updated_at_ms: None,
|
||||
archived: false,
|
||||
has_user_event: false,
|
||||
is_subagent: false,
|
||||
needs_sync: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_directory_and_lexical_alias_have_the_same_stable_identity() {
|
||||
let fixture = TestDirectory::new();
|
||||
let folder = fixture.path.join("Workspace");
|
||||
fs::create_dir(&folder).unwrap();
|
||||
let direct = directory_identity(&folder.display().to_string());
|
||||
let alias = directory_identity(&folder.join(".").display().to_string());
|
||||
assert!(direct.starts_with("filesystem:"));
|
||||
assert_eq!(direct, alias);
|
||||
assert_eq!(direct, directory_identity(&folder.display().to_string()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symbolic_directory_alias_resolves_to_the_same_identity() {
|
||||
let fixture = TestDirectory::new();
|
||||
let folder = fixture.path.join("Workspace");
|
||||
let alias = fixture.path.join("WorkspaceAlias");
|
||||
fs::create_dir(&folder).unwrap();
|
||||
std::os::unix::fs::symlink(&folder, &alias).unwrap();
|
||||
assert_eq!(
|
||||
directory_identity(&folder.display().to_string()),
|
||||
directory_identity(&alias.display().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_only_names_follow_the_actual_fixture_filesystem() {
|
||||
let fixture = TestDirectory::new();
|
||||
let upper = fixture.path.join("CaseDirectory");
|
||||
let lower = fixture.path.join("casedirectory");
|
||||
fs::create_dir(&upper).unwrap();
|
||||
let separate_directories = match fs::create_dir(&lower) {
|
||||
Ok(()) => true,
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => false,
|
||||
Err(error) => panic!("create case-only directory fixture: {error}"),
|
||||
};
|
||||
let upper_key = directory_identity(&upper.display().to_string());
|
||||
let lower_key = directory_identity(&lower.display().to_string());
|
||||
assert!(upper_key.starts_with("filesystem:"));
|
||||
assert!(lower_key.starts_with("filesystem:"));
|
||||
if separate_directories {
|
||||
assert_ne!(
|
||||
upper_key, lower_key,
|
||||
"case-sensitive directories must remain distinct"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
upper_key, lower_key,
|
||||
"case aliases of the same directory must match"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_never_borrow_identity_from_the_application_working_directory() {
|
||||
for raw in [".", "./", "src", "foo", "C:Project", " relative\\Name "] {
|
||||
assert_eq!(directory_identity(raw), format!("path:{raw}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_nondirectory_paths_keep_the_complete_original_spelling() {
|
||||
let fixture = TestDirectory::new();
|
||||
for suffix in ["Missing", "missing", "Missing\\Name ", " missing "] {
|
||||
let raw = fixture.path.join(suffix).display().to_string();
|
||||
assert_eq!(directory_identity(&raw), format!("path:{raw}"));
|
||||
}
|
||||
let file = fixture.path.join("a-file");
|
||||
fs::write(&file, b"read-only identity lookup must not alter contents").unwrap();
|
||||
let raw = file.display().to_string();
|
||||
assert_eq!(directory_identity(&raw), format!("path:{raw}"));
|
||||
assert_eq!(
|
||||
fs::read(&file).unwrap(),
|
||||
b"read-only identity lookup must not alter contents"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unreadable_directory_falls_back_without_normalizing_its_name() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let fixture = TestDirectory::new();
|
||||
let directory = fixture.path.join("UnreadableDirectory");
|
||||
fs::create_dir(&directory).unwrap();
|
||||
fs::set_permissions(&directory, fs::Permissions::from_mode(0)).unwrap();
|
||||
let raw = directory.display().to_string();
|
||||
let readable_by_process = File::open(&directory).is_ok();
|
||||
let identity = directory_identity(&raw);
|
||||
fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
if !readable_by_process {
|
||||
assert_eq!(identity, format!("path:{raw}"));
|
||||
} else {
|
||||
// A privileged test runner can bypass the permission restriction.
|
||||
assert!(identity.starts_with("filesystem:"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_native_identifiers_are_rejected_and_full_windows_file_ids_are_used() {
|
||||
assert!(unix_directory_identity(1, 0, None).is_none());
|
||||
assert!(windows_directory_identity(1, &[0; 16], None).is_none());
|
||||
let mut first = [0; 16];
|
||||
first[0] = 7;
|
||||
let mut second = first;
|
||||
second[15] = 1;
|
||||
assert_ne!(
|
||||
windows_directory_identity(1, &first, None),
|
||||
windows_directory_identity(1, &second, None),
|
||||
"ReFS IDs that differ only beyond the old 64-bit index must not merge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_generation_includes_epoch_sign_full_seconds_and_nanoseconds() {
|
||||
use std::time::Duration;
|
||||
let created = UNIX_EPOCH + Duration::new(1234, 17);
|
||||
let original = unix_directory_identity(1, 9, Some(created)).unwrap();
|
||||
assert!(original.birth_verified);
|
||||
assert_eq!(
|
||||
original,
|
||||
unix_directory_identity(1, 9, Some(created)).unwrap()
|
||||
);
|
||||
for new_generation in [
|
||||
UNIX_EPOCH + Duration::new(1234, 18),
|
||||
UNIX_EPOCH + Duration::new(1234 + (1u64 << 32), 17),
|
||||
UNIX_EPOCH - Duration::new(1234, 17),
|
||||
] {
|
||||
assert_ne!(
|
||||
original,
|
||||
unix_directory_identity(1, 9, Some(new_generation)).unwrap(),
|
||||
"reused inode numbers from another directory generation must get different keys"
|
||||
);
|
||||
}
|
||||
let file_id = [7; 16];
|
||||
assert_ne!(
|
||||
windows_directory_identity(1, &file_id, Some(created)),
|
||||
windows_directory_identity(1, &file_id, Some(created + Duration::from_nanos(1)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistent_pin_scope_requires_verified_birth_and_preserves_raw_fallback_paths() {
|
||||
let transient = unix_directory_identity(1, 9, None).unwrap();
|
||||
assert!(transient.key.starts_with("filesystem:"));
|
||||
assert!(!transient.birth_verified);
|
||||
for path in ["/Home/Codex", "/Home/codex", "/Home/Codex\\ "] {
|
||||
assert_eq!(pin_scope_key(path, &transient), format!("path:{path}"));
|
||||
}
|
||||
let verified = unix_directory_identity(1, 9, Some(UNIX_EPOCH)).unwrap();
|
||||
assert_eq!(pin_scope_key("/Home/Codex", &verified), verified.key);
|
||||
assert_eq!(pin_scope_key("/Home/codex", &verified), verified.key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_directory_contents_does_not_change_birth_identity_or_pin_scope() {
|
||||
let fixture = TestDirectory::new();
|
||||
let directory = fixture.path.join("Workspace");
|
||||
fs::create_dir(&directory).unwrap();
|
||||
let raw = directory.display().to_string();
|
||||
let before = directory_lookup(&raw);
|
||||
fs::write(directory.join("first.txt"), b"first").unwrap();
|
||||
fs::write(directory.join("first.txt"), b"updated").unwrap();
|
||||
fs::write(directory.join("second.txt"), b"second").unwrap();
|
||||
fs::remove_file(directory.join("first.txt")).unwrap();
|
||||
let after = directory_lookup(&raw);
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"mtime/ctime changes must not replace the directory birth identity"
|
||||
);
|
||||
assert_eq!(pin_scope_key(&raw, &before), pin_scope_key(&raw, &after));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_identities_include_only_its_home_and_loaded_workspaces_and_isolate_homes() {
|
||||
let fixture = TestDirectory::new();
|
||||
let first_home = fixture.path.join("CodexHomeA");
|
||||
let second_home = fixture.path.join("CodexHomeB");
|
||||
let workspace = fixture.path.join("Project");
|
||||
let unused = fixture.path.join("Unused");
|
||||
for directory in [&first_home, &second_home, &workspace, &unused] {
|
||||
fs::create_dir(directory).unwrap();
|
||||
}
|
||||
let config = first_home.join("config.toml");
|
||||
fs::write(&config, b"model_provider = \"openai\"\n").unwrap();
|
||||
let cwd = workspace.display().to_string();
|
||||
let sessions = vec![
|
||||
preview("first", Some(cwd.clone())),
|
||||
preview("second", Some(cwd.clone())),
|
||||
preview("unknown", None),
|
||||
];
|
||||
let (first, first_scope) = session_directory_identities(&first_home, &sessions);
|
||||
let (second, second_scope) = session_directory_identities(&second_home, &sessions);
|
||||
assert_eq!(first.len(), 2);
|
||||
assert!(first.contains_key(&first_home.display().to_string()));
|
||||
assert!(first.contains_key(&cwd));
|
||||
assert!(!first.contains_key(&unused.display().to_string()));
|
||||
assert_ne!(
|
||||
first[&first_home.display().to_string()],
|
||||
second[&second_home.display().to_string()]
|
||||
);
|
||||
assert_ne!(first_scope, second_scope);
|
||||
assert_eq!(first[&cwd], second[&cwd]);
|
||||
assert_eq!(fs::read(&config).unwrap(), b"model_provider = \"openai\"\n");
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ mod app_server;
|
||||
mod backup;
|
||||
mod catalog;
|
||||
mod delete;
|
||||
mod directory_identity;
|
||||
mod export;
|
||||
mod global_state;
|
||||
mod rollout_stream;
|
||||
mod storage;
|
||||
mod sync;
|
||||
mod transaction;
|
||||
@@ -23,8 +25,9 @@ pub(crate) use storage::{
|
||||
scan_sqlite, sqlite_session_db_paths,
|
||||
};
|
||||
pub(crate) use storage::{
|
||||
session_project_title, session_titles_by_id, sqlite_candidate_paths,
|
||||
sqlite_candidate_paths_with_timeout, usage_thread_identities, UsageThreadIdentity,
|
||||
session_project_title, session_titles_by_id, source_kind_is_internal, sqlite_candidate_paths,
|
||||
sqlite_candidate_paths_with_timeout, thread_source_is_internal, usage_thread_identities,
|
||||
UsageThreadIdentity,
|
||||
};
|
||||
pub(crate) use sync::{session_sync_status_inner, sync_sessions_provider_inner};
|
||||
pub(crate) use types::{SessionSyncResult, SessionSyncStatus};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
use super::backup::{create_provider_sync_backup, prune_provider_sync_backups};
|
||||
use super::catalog::{scan_catalog_sync, CatalogSyncScan};
|
||||
use super::catalog::{scan_catalog_internal_thread_ids, scan_catalog_sync, CatalogSyncScan};
|
||||
use super::directory_identity::session_directory_identities;
|
||||
use super::storage::{
|
||||
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
|
||||
list_session_previews_with_paths, scan_provider_rollouts, scan_rollouts_for_thread_ids,
|
||||
scan_sqlite_with_paths, SqliteDiscovery,
|
||||
list_session_previews_with_paths, rollout_internal_thread_ids, scan_provider_rollouts,
|
||||
scan_rollouts_for_thread_ids, scan_sqlite_with_paths, SqliteDiscovery,
|
||||
};
|
||||
use super::transaction::{
|
||||
execute_provider_sync_mutation, mutation_error, prepare_sqlite_updates, rollback_mutation,
|
||||
@@ -85,16 +86,22 @@ fn scan_provider_sync_data(
|
||||
target_provider: &str,
|
||||
discovery: &SqliteDiscovery,
|
||||
) -> Result<ProviderSyncScan> {
|
||||
let active_sqlite = scan_sqlite_with_paths(
|
||||
let active_unclassified = scan_sqlite_with_paths(
|
||||
&discovery.active_paths,
|
||||
&RolloutScan::default(),
|
||||
target_provider,
|
||||
)?;
|
||||
let mut sqlite = scan_sqlite_with_paths(
|
||||
&discovery.thread_paths,
|
||||
&RolloutScan::default(),
|
||||
target_provider,
|
||||
)?;
|
||||
let (mut internal_thread_ids, _classification_failures) =
|
||||
rollout_internal_thread_ids(codex_dir, &active_unclassified.rollout_paths_by_thread_id);
|
||||
internal_thread_ids.extend(scan_catalog_internal_thread_ids(&discovery.related_paths)?);
|
||||
let classification = RolloutScan {
|
||||
internal_thread_ids,
|
||||
..RolloutScan::default()
|
||||
};
|
||||
let active_sqlite =
|
||||
scan_sqlite_with_paths(&discovery.active_paths, &classification, target_provider)?;
|
||||
let mut sqlite =
|
||||
scan_sqlite_with_paths(&discovery.thread_paths, &classification, target_provider)?;
|
||||
let mut syncable_thread_ids = sqlite.syncable_thread_ids.clone();
|
||||
let mut archived_thread_ids = sqlite.archived_thread_ids.clone();
|
||||
let mut subagent_thread_ids = sqlite.subagent_thread_ids.clone();
|
||||
@@ -111,6 +118,7 @@ fn scan_provider_sync_data(
|
||||
}
|
||||
let mut excluded_thread_ids = archived_thread_ids.clone();
|
||||
excluded_thread_ids.extend(subagent_thread_ids.iter().cloned());
|
||||
excluded_thread_ids.extend(classification.internal_thread_ids.iter().cloned());
|
||||
// Without an active authority, conflicting legacy classifications stay conservatively excluded.
|
||||
syncable_thread_ids.retain(|id| !excluded_thread_ids.contains(id));
|
||||
mismatched_thread_ids.retain(|id| syncable_thread_ids.contains(id));
|
||||
@@ -120,15 +128,41 @@ fn scan_provider_sync_data(
|
||||
sqlite.mismatched_thread_ids = mismatched_thread_ids;
|
||||
sqlite.mismatched_threads = sqlite.mismatched_thread_ids.len();
|
||||
|
||||
let indexed_rollouts = if active_sqlite.sqlite_dbs > 0 {
|
||||
scan_provider_buckets(codex_dir, target_provider, &active_sqlite)?
|
||||
} else {
|
||||
scan_provider_buckets(codex_dir, target_provider, &sqlite)?
|
||||
// Only diagnostics from this authority scan are needed below. Drop its
|
||||
// planned file copies before scanning the complete provider history.
|
||||
let (indexed_rollout_failures, indexed_blocked_failures) = {
|
||||
let rollouts = if active_sqlite.sqlite_dbs > 0 {
|
||||
scan_provider_buckets(codex_dir, target_provider, &active_sqlite)?
|
||||
} else {
|
||||
scan_provider_buckets(codex_dir, target_provider, &sqlite)?
|
||||
};
|
||||
(rollouts.scan_failures, rollouts.blocked_failures)
|
||||
};
|
||||
let legacy_index_warnings = scan_legacy_index_warnings(codex_dir, target_provider, discovery);
|
||||
let mut rollouts = scan_provider_rollouts(codex_dir, target_provider, &excluded_thread_ids)?;
|
||||
let mut rollouts = scan_provider_rollouts(
|
||||
codex_dir,
|
||||
target_provider,
|
||||
&excluded_thread_ids,
|
||||
&active_unclassified.rollout_paths_by_thread_id,
|
||||
)?;
|
||||
// Provider synchronization changes provider routing only; cwd remains independently managed.
|
||||
rollouts.cwd_by_thread_id.clear();
|
||||
// Rollout metadata can reveal an internal thread even when the SQLite row
|
||||
// has no source column (or an older index still calls it a user thread).
|
||||
excluded_thread_ids.extend(rollouts.internal_thread_ids.iter().cloned());
|
||||
rollouts
|
||||
.internal_thread_ids
|
||||
.extend(classification.internal_thread_ids.iter().cloned());
|
||||
sqlite
|
||||
.subagent_thread_ids
|
||||
.extend(rollouts.internal_thread_ids.iter().cloned());
|
||||
sqlite
|
||||
.syncable_thread_ids
|
||||
.retain(|id| !excluded_thread_ids.contains(id));
|
||||
sqlite
|
||||
.mismatched_thread_ids
|
||||
.retain(|id| !excluded_thread_ids.contains(id));
|
||||
sqlite.mismatched_threads = sqlite.mismatched_thread_ids.len();
|
||||
syncable_thread_ids.extend(rollouts.thread_ids.iter().cloned());
|
||||
syncable_thread_ids.retain(|id| !excluded_thread_ids.contains(id));
|
||||
let catalog = scan_catalog_sync(
|
||||
@@ -141,27 +175,24 @@ fn scan_provider_sync_data(
|
||||
let mut scan_failures = discovery.active_scan_failures.clone();
|
||||
scan_failures.extend(active_sqlite.scan_failures.iter().cloned());
|
||||
scan_failures.extend(sqlite.scan_failures.iter().cloned());
|
||||
scan_failures.extend(indexed_rollouts.scan_failures.iter().cloned());
|
||||
scan_failures.extend(indexed_rollout_failures.iter().cloned());
|
||||
scan_failures.extend(indexed_blocked_failures);
|
||||
// A size/buffer limit means the rollout was not completely inspected.
|
||||
// This is blocking even for an orphan, rather than a success with a warning.
|
||||
scan_failures.extend(rollouts.blocked_failures.iter().cloned());
|
||||
// A failed eligible rollout read/parse is an incomplete check, including
|
||||
// unindexed files. It must not become an apparently successful sync.
|
||||
scan_failures.extend(rollouts.scan_failures.iter().cloned());
|
||||
for path in &discovery.unreadable_paths {
|
||||
scan_failures.push(format!("无法读取会话数据库: {}", path.display()));
|
||||
}
|
||||
|
||||
let indexed_failures = indexed_rollouts
|
||||
.scan_failures
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<HashSet<_>>();
|
||||
let mut warnings = rollouts.warnings.clone();
|
||||
// The full provider/index scans below own warnings and blocking failures;
|
||||
// repeating the lightweight identity scan's messages would duplicate them.
|
||||
warnings.extend(active_sqlite.warnings.iter().cloned());
|
||||
warnings.extend(sqlite.warnings.iter().cloned());
|
||||
warnings.extend(legacy_index_warnings);
|
||||
warnings.extend(
|
||||
rollouts
|
||||
.scan_failures
|
||||
.iter()
|
||||
.filter(|failure| !indexed_failures.contains(*failure))
|
||||
.map(|failure| format!("已跳过未进入会话索引的异常文件:{failure}")),
|
||||
);
|
||||
let mut seen = HashSet::new();
|
||||
scan_failures.retain(|failure| seen.insert(failure.clone()));
|
||||
seen.clear();
|
||||
@@ -215,8 +246,12 @@ pub(super) fn session_sync_status_with_discovery(
|
||||
Err(error) => {
|
||||
let mut failures = scan.scan_failures.clone();
|
||||
failures.push(format!("无法读取当前活动会话列表: {error}"));
|
||||
let (directory_identities, pin_scope_key) =
|
||||
session_directory_identities(codex_dir, &[]);
|
||||
return Ok(SessionSyncStatus {
|
||||
codex_dir: codex_dir.display().to_string(),
|
||||
directory_identities,
|
||||
pin_scope_key: Some(pin_scope_key),
|
||||
target_provider: target,
|
||||
rollout_files: scan.rollouts.rollout_files,
|
||||
session_meta_count: scan.rollouts.session_meta_count,
|
||||
@@ -247,15 +282,21 @@ pub(super) fn session_sync_status_with_discovery(
|
||||
let mut mismatched_ids = sqlite_mismatch_ids.clone();
|
||||
mismatched_ids.extend(scan.rollouts.mismatched_thread_ids.iter().cloned());
|
||||
for session in &mut sessions {
|
||||
if !session.archived && !session.is_subagent && mismatched_ids.contains(&session.id) {
|
||||
session.needs_sync = true;
|
||||
}
|
||||
// The same eligibility set drives both UI status and every mutation path.
|
||||
session.needs_sync = !session.archived
|
||||
&& !session.is_subagent
|
||||
&& scan.syncable_thread_ids.contains(&session.id)
|
||||
&& mismatched_ids.contains(&session.id);
|
||||
}
|
||||
let needs_sync = !scan.rollouts.changes.is_empty()
|
||||
|| !scan.rollouts.mismatched_thread_ids.is_empty()
|
||||
|| scan.sqlite.mismatched_threads > 0
|
||||
|| scan.catalog.total_updates() > 0;
|
||||
let (directory_identities, pin_scope_key) = session_directory_identities(codex_dir, &sessions);
|
||||
Ok(SessionSyncStatus {
|
||||
codex_dir: codex_dir.display().to_string(),
|
||||
directory_identities,
|
||||
pin_scope_key: Some(pin_scope_key),
|
||||
target_provider: target,
|
||||
rollout_files: scan.rollouts.rollout_files,
|
||||
session_meta_count: scan.rollouts.session_meta_count,
|
||||
@@ -359,6 +400,7 @@ where
|
||||
backup_dir: String::new(),
|
||||
});
|
||||
}
|
||||
drop(preflight_scan);
|
||||
|
||||
hook(MutationPoint::BeforeSqliteLock)?;
|
||||
let mut pending_sqlite = prepare_sqlite_updates(&discovery.related_paths)?;
|
||||
@@ -379,6 +421,14 @@ where
|
||||
{
|
||||
rollback_open_transactions(&mut pending_sqlite);
|
||||
let status = session_sync_status_with_discovery(&codex_dir, target_provider, &discovery)?;
|
||||
if !status.scan_complete {
|
||||
return Err(scan_failure_error(&status.scan_failures));
|
||||
}
|
||||
if status.needs_sync {
|
||||
return Err(CodexxError::Config(
|
||||
"会话状态在检查后发生变化,未完成同步;请重新检查。".to_string(),
|
||||
));
|
||||
}
|
||||
return Ok(SessionSyncResult {
|
||||
status,
|
||||
updated_rollouts: 0,
|
||||
@@ -426,25 +476,32 @@ where
|
||||
}
|
||||
};
|
||||
|
||||
let prune_warning = prune_provider_sync_backups(&codex_dir).err();
|
||||
let mut status = session_sync_status_with_discovery(&codex_dir, target_provider, &discovery)
|
||||
.and_then(|status| {
|
||||
if !status.scan_complete {
|
||||
return Err(scan_failure_error(&status.scan_failures));
|
||||
}
|
||||
if status.needs_sync {
|
||||
return Err(CodexxError::Config(
|
||||
"会话日志与索引仍不一致,未完成同步;请重新检查。".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(status)
|
||||
})
|
||||
.map_err(|error| {
|
||||
CodexxError::Config(format!(
|
||||
"同步已完成,但刷新会话列表失败,请重新进入页面:{error}"
|
||||
))
|
||||
let recovery_errors = rollback_mutation(&journal, &mut pending_sqlite);
|
||||
mutation_error(
|
||||
CodexxError::Config(format!("同步后复查未通过,本轮同步未完成:{error}")),
|
||||
recovery_errors,
|
||||
)
|
||||
})?;
|
||||
let prune_warning = prune_provider_sync_backups(&codex_dir).err();
|
||||
status.backup_dir = Some(backup.dir.display().to_string());
|
||||
if prune_warning.is_some() {
|
||||
status
|
||||
.warnings
|
||||
.push("同步已完成,但旧备份暂未清理。".to_string());
|
||||
}
|
||||
if !mutation.skipped_rollouts.is_empty() {
|
||||
status.warnings.push(format!(
|
||||
"有 {} 个会话正在使用,已跳过;退出 Codex 后再同步即可。",
|
||||
mutation.skipped_rollouts.len()
|
||||
));
|
||||
}
|
||||
Ok(SessionSyncResult {
|
||||
status,
|
||||
updated_rollouts: mutation.applied_rollouts,
|
||||
@@ -457,9 +514,12 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::{BufRead, Read, Seek, SeekFrom};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
const SHARED_SESSION_PROVIDER: &str = "custom";
|
||||
const LEGACY_LIMIT: u64 = 32 * 1024 * 1024;
|
||||
|
||||
fn temp_codex_dir(label: &str) -> std::path::PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -644,6 +704,383 @@ mod tests {
|
||||
.expect("write rollout");
|
||||
}
|
||||
|
||||
fn pad_rollout_to_size(path: &Path, size: u64) {
|
||||
let mut file = fs::OpenOptions::new().append(true).open(path).unwrap();
|
||||
let mut remaining = size.checked_sub(file.metadata().unwrap().len()).unwrap();
|
||||
let padding = [b' '; 64 * 1024];
|
||||
while remaining > 0 {
|
||||
let count = remaining.min(padding.len() as u64) as usize;
|
||||
file.write_all(&padding[..count]).unwrap();
|
||||
remaining -= count as u64;
|
||||
}
|
||||
}
|
||||
|
||||
fn rollout_digest(path: &Path) -> Vec<u8> {
|
||||
rollout_digest_from_offset(path, 0)
|
||||
}
|
||||
|
||||
fn rollout_digest_from_offset(path: &Path, offset: u64) -> Vec<u8> {
|
||||
let mut file = fs::File::open(path).unwrap();
|
||||
file.seek(SeekFrom::Start(offset)).unwrap();
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0; 64 * 1024];
|
||||
loop {
|
||||
let count = file.read(&mut buffer).unwrap();
|
||||
if count == 0 {
|
||||
return digest.finalize().to_vec();
|
||||
}
|
||||
digest.update(&buffer[..count]);
|
||||
}
|
||||
}
|
||||
|
||||
fn append_chat_record(path: &Path) {
|
||||
fs::OpenOptions::new().append(true).open(path).unwrap()
|
||||
.write_all(b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"keep this conversation\"}}\n")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn read_first_rollout_record(path: &Path) -> String {
|
||||
let mut first_record = String::new();
|
||||
std::io::BufReader::new(fs::File::open(path).unwrap())
|
||||
.read_line(&mut first_record)
|
||||
.unwrap();
|
||||
first_record
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_rollouts_below_at_and_above_legacy_limit_sync_logs_databases_and_catalogs() {
|
||||
for (name, size) in [
|
||||
("normal", 512),
|
||||
("below-old-limit", LEGACY_LIMIT - 1),
|
||||
("exact-old-limit", LEGACY_LIMIT),
|
||||
("above-old-limit", LEGACY_LIMIT + 1),
|
||||
] {
|
||||
let dir = temp_codex_dir(name);
|
||||
write_config(&dir, "custom");
|
||||
let id = "019f6000-0000-7000-8000-000000000701";
|
||||
let rollout = write_rollout(&dir, id, "openai");
|
||||
let old_record_len = read_first_rollout_record(&rollout).len() as u64;
|
||||
append_chat_record(&rollout);
|
||||
pad_rollout_to_size(&rollout, size);
|
||||
let conversation_before = rollout_digest_from_offset(&rollout, old_record_len);
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, "openai", &rollout);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, "openai")]);
|
||||
|
||||
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(status.scan_complete, "{name}: {:?}", status.scan_failures);
|
||||
assert!(status.needs_sync, "{name}");
|
||||
let result =
|
||||
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(
|
||||
result.status.scan_complete && !result.status.needs_sync,
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(result.updated_rollouts, 1, "{name}");
|
||||
assert_eq!(thread_provider(&database, id), "custom", "{name}");
|
||||
assert_eq!(catalog_provider(&catalog, id), "custom", "{name}");
|
||||
let first_record = read_first_rollout_record(&rollout);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&first_record).unwrap()["payload"]
|
||||
["model_provider"],
|
||||
"custom"
|
||||
);
|
||||
assert_eq!(fs::metadata(&rollout).unwrap().len(), size, "{name}");
|
||||
assert_eq!(
|
||||
rollout_digest_from_offset(&rollout, first_record.len() as u64),
|
||||
conversation_before,
|
||||
"conversation bytes must be preserved: {name}"
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_ninety_seven_mib_single_chat_line_syncs_without_losing_conversation_bytes() {
|
||||
let dir = temp_codex_dir("streamed-97-mib-chat-record");
|
||||
write_config(&dir, "custom");
|
||||
let id = "019f6000-0000-7000-8000-000000000709";
|
||||
let rollout = write_rollout(&dir, id, "openai");
|
||||
let first_record_len = read_first_rollout_record(&rollout).len() as u64;
|
||||
let total_size = 97 * 1024 * 1024u64;
|
||||
let prefix =
|
||||
b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"";
|
||||
let suffix = b"\"}}\n";
|
||||
let mut file = fs::OpenOptions::new().append(true).open(&rollout).unwrap();
|
||||
file.write_all(prefix).unwrap();
|
||||
let mut remaining =
|
||||
total_size - first_record_len - prefix.len() as u64 - suffix.len() as u64;
|
||||
let body_chunk = [b'x'; 64 * 1024];
|
||||
while remaining > 0 {
|
||||
let count = remaining.min(body_chunk.len() as u64) as usize;
|
||||
file.write_all(&body_chunk[..count]).unwrap();
|
||||
remaining -= count as u64;
|
||||
}
|
||||
file.write_all(suffix).unwrap();
|
||||
file.sync_all().unwrap();
|
||||
drop(file);
|
||||
let conversation_before = rollout_digest_from_offset(&rollout, first_record_len);
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, "openai", &rollout);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, "openai")]);
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None)
|
||||
.expect("a large valid single JSONL record must synchronize successfully");
|
||||
|
||||
assert!(
|
||||
result.status.scan_complete && !result.status.needs_sync,
|
||||
"{:?}",
|
||||
result.status.scan_failures
|
||||
);
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert_eq!(thread_provider(&database, id), "custom");
|
||||
assert_eq!(catalog_provider(&catalog, id), "custom");
|
||||
let first_record = read_first_rollout_record(&rollout);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&first_record).unwrap()["payload"]
|
||||
["model_provider"],
|
||||
"custom"
|
||||
);
|
||||
assert_eq!(fs::metadata(&rollout).unwrap().len(), total_size);
|
||||
assert_eq!(
|
||||
rollout_digest_from_offset(&rollout, first_record.len() as u64),
|
||||
conversation_before,
|
||||
"the large chat record must retain every original byte"
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_multiple_rollouts_exceeding_legacy_snapshot_budget_sync_together() {
|
||||
let dir = temp_codex_dir("streamed-multi-large-rollout-budget");
|
||||
write_config(&dir, "custom");
|
||||
let ids = [
|
||||
"019f6000-0000-7000-8000-000000000711",
|
||||
"019f6000-0000-7000-8000-000000000712",
|
||||
"019f6000-0000-7000-8000-000000000713",
|
||||
];
|
||||
let file_size = 45 * 1024 * 1024u64;
|
||||
assert!(file_size * ids.len() as u64 > 128 * 1024 * 1024);
|
||||
let mut rollouts = Vec::new();
|
||||
let mut conversation_digests = Vec::new();
|
||||
for id in ids {
|
||||
let rollout = write_rollout(&dir, id, "openai");
|
||||
let first_record_len = read_first_rollout_record(&rollout).len() as u64;
|
||||
append_chat_record(&rollout);
|
||||
pad_rollout_to_size(&rollout, file_size);
|
||||
conversation_digests.push(rollout_digest_from_offset(&rollout, first_record_len));
|
||||
rollouts.push(rollout);
|
||||
}
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, ids[0], "openai", &rollouts[0]);
|
||||
let conn = Connection::open(&database).unwrap();
|
||||
for index in 1..ids.len() {
|
||||
conn.execute(
|
||||
"INSERT INTO threads (id, model_provider, title, rollout_path) VALUES (?1, 'openai', 'large history', ?2)",
|
||||
(ids[index], rollouts[index].display().to_string()),
|
||||
).unwrap();
|
||||
}
|
||||
drop(conn);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &ids.map(|id| (id, "openai")));
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None)
|
||||
.expect("the combined size of valid rollouts must not block synchronization");
|
||||
|
||||
assert!(
|
||||
result.status.scan_complete && !result.status.needs_sync,
|
||||
"{:?}",
|
||||
result.status.scan_failures
|
||||
);
|
||||
assert_eq!(result.updated_rollouts, ids.len());
|
||||
for index in 0..ids.len() {
|
||||
assert_eq!(thread_provider(&database, ids[index]), "custom");
|
||||
assert_eq!(catalog_provider(&catalog, ids[index]), "custom");
|
||||
let first_record = read_first_rollout_record(&rollouts[index]);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&first_record).unwrap()["payload"]
|
||||
["model_provider"],
|
||||
"custom"
|
||||
);
|
||||
assert_eq!(fs::metadata(&rollouts[index]).unwrap().len(), file_size);
|
||||
assert_eq!(
|
||||
rollout_digest_from_offset(&rollouts[index], first_record.len() as u64),
|
||||
conversation_digests[index],
|
||||
"conversation bytes must remain unchanged for {}",
|
||||
ids[index]
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_invalid_jsonl_rollout_reports_parse_failure_and_keeps_indexes_unchanged() {
|
||||
for (name, file_provider, database_provider) in [
|
||||
("file-and-index-old", "openai", "openai"),
|
||||
("index-already-target", "openai", "custom"),
|
||||
("first-record-matches", "custom", "custom"),
|
||||
] {
|
||||
let dir = temp_codex_dir(name);
|
||||
write_config(&dir, "custom");
|
||||
let id = "019f6000-0000-7000-8000-000000000702";
|
||||
let rollout = write_rollout(&dir, id, file_provider);
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&rollout)
|
||||
.unwrap()
|
||||
.set_len(LEGACY_LIMIT + 1)
|
||||
.unwrap();
|
||||
let before = rollout_digest(&rollout);
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, database_provider, &rollout);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, database_provider)]);
|
||||
|
||||
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(!status.scan_complete, "{name}");
|
||||
assert!(
|
||||
status
|
||||
.scan_failures
|
||||
.iter()
|
||||
.any(|failure| failure.contains("JSONL")),
|
||||
"{name}: {:?}",
|
||||
status.scan_failures
|
||||
);
|
||||
let error =
|
||||
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap_err();
|
||||
assert!(error.to_string().contains("JSONL"), "{name}: {error}");
|
||||
assert_eq!(thread_provider(&database, id), database_provider, "{name}");
|
||||
assert_eq!(catalog_provider(&catalog, id), database_provider, "{name}");
|
||||
assert_eq!(rollout_digest(&rollout), before, "{name}");
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_invalid_orphan_blocks_sync_without_changing_normal_logs_or_indexes() {
|
||||
let dir = temp_codex_dir("large-invalid-orphan-blocked");
|
||||
write_config(&dir, "custom");
|
||||
let ordinary_id = "019f6000-0000-7000-8000-000000000703";
|
||||
let ordinary = write_rollout(&dir, ordinary_id, "openai");
|
||||
let before = fs::read(&ordinary).unwrap();
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, ordinary_id, "openai", &ordinary);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(ordinary_id, "openai")]);
|
||||
let orphan_id = "019f6000-0000-7000-8000-000000000704";
|
||||
let invalid_orphan = write_rollout(&dir, orphan_id, "openai");
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&invalid_orphan)
|
||||
.unwrap()
|
||||
.set_len(LEGACY_LIMIT + 1)
|
||||
.unwrap();
|
||||
let orphan_before = rollout_digest(&invalid_orphan);
|
||||
|
||||
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(!status.scan_complete && status.needs_sync);
|
||||
let error =
|
||||
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap_err();
|
||||
assert!(error.to_string().contains("JSONL"), "{error}");
|
||||
assert_eq!(fs::read(&ordinary).unwrap(), before);
|
||||
assert_eq!(rollout_digest(&invalid_orphan), orphan_before);
|
||||
assert_eq!(thread_provider(&database, ordinary_id), "openai");
|
||||
assert_eq!(catalog_provider(&catalog, ordinary_id), "openai");
|
||||
assert!(catalog_provider_and_visibility(&catalog, orphan_id).is_none());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_orphan_read_or_parse_cannot_be_reported_as_successful_sync() {
|
||||
for suffix in [b"\xff".as_slice(), b"not-json\n".as_slice()] {
|
||||
let dir = temp_codex_dir("failed-orphan-check-blocks-sync");
|
||||
write_config(&dir, "custom");
|
||||
let id = "019f6000-0000-7000-8000-000000000705";
|
||||
let ordinary = write_rollout(&dir, id, "openai");
|
||||
let original = fs::read(&ordinary).unwrap();
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, "openai", &ordinary);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, "openai")]);
|
||||
let orphan = write_rollout(&dir, "019f6000-0000-7000-8000-000000000706", "openai");
|
||||
fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&orphan)
|
||||
.unwrap()
|
||||
.write_all(suffix)
|
||||
.unwrap();
|
||||
let orphan_original = fs::read(&orphan).unwrap();
|
||||
|
||||
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(!status.scan_complete && !status.scan_failures.is_empty());
|
||||
assert!(sync_sessions_provider_inner(Some(dir.display().to_string()), None).is_err());
|
||||
assert_eq!(thread_provider(&database, id), "openai");
|
||||
assert_eq!(catalog_provider(&catalog, id), "openai");
|
||||
assert_eq!(fs::read(&ordinary).unwrap(), original);
|
||||
assert_eq!(fs::read(&orphan).unwrap(), orphan_original);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_final_recheck_rolls_back_committed_logs_and_indexes() {
|
||||
for scenario in ["invalid", "large-invalid", "new-mismatch"] {
|
||||
let dir = temp_codex_dir("failed-final-recheck");
|
||||
write_config(&dir, "custom");
|
||||
let id = "019f6000-0000-7000-8000-000000000707";
|
||||
let ordinary = write_rollout(&dir, id, "openai");
|
||||
let original = fs::read(&ordinary).unwrap();
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, "openai", &ordinary);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, "openai")]);
|
||||
let late = dir.join("sessions/rollout-test-019f6000-0000-7000-8000-000000000708.jsonl");
|
||||
let mut late_digest = None;
|
||||
|
||||
let error =
|
||||
sync_sessions_provider_with_hook(Some(dir.display().to_string()), None, |point| {
|
||||
if matches!(point, MutationPoint::AfterSqliteCommit(_)) && !late.exists() {
|
||||
if scenario == "invalid" {
|
||||
fs::write(&late, b"\xff").unwrap();
|
||||
} else {
|
||||
write_rollout_at(
|
||||
&late,
|
||||
"019f6000-0000-7000-8000-000000000708",
|
||||
"openai",
|
||||
);
|
||||
if scenario == "large-invalid" {
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&late)
|
||||
.unwrap()
|
||||
.set_len(LEGACY_LIMIT + 1)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
late_digest = Some(rollout_digest(&late));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("a failed final check must not return a successful sync");
|
||||
|
||||
assert!(
|
||||
error.to_string().contains("同步后复查未通过"),
|
||||
"{scenario}: {error}"
|
||||
);
|
||||
assert_eq!(thread_provider(&database, id), "openai", "{scenario}");
|
||||
assert_eq!(catalog_provider(&catalog, id), "openai", "{scenario}");
|
||||
assert_eq!(fs::read(&ordinary).unwrap(), original, "{scenario}");
|
||||
assert_eq!(
|
||||
rollout_digest(&late),
|
||||
late_digest.unwrap(),
|
||||
"preserve external file: {scenario}"
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn write_subagent_rollout(
|
||||
codex_dir: &Path,
|
||||
id: &str,
|
||||
@@ -672,6 +1109,176 @@ mod tests {
|
||||
path
|
||||
}
|
||||
|
||||
fn rows_except_parent(
|
||||
path: &Path,
|
||||
table: &str,
|
||||
id_column: &str,
|
||||
parent: &str,
|
||||
) -> Vec<Vec<rusqlite::types::Value>> {
|
||||
let conn = Connection::open(path).expect("open snapshot database");
|
||||
let mut stmt = conn
|
||||
.prepare(&format!(
|
||||
"SELECT * FROM {table} WHERE {id_column} <> ?1 ORDER BY {id_column}"
|
||||
))
|
||||
.expect("prepare row snapshot");
|
||||
let column_count = stmt.column_count();
|
||||
stmt.query_map([parent], |row| {
|
||||
(0..column_count).map(|index| row.get(index)).collect()
|
||||
})
|
||||
.expect("read row snapshots")
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.expect("collect row snapshots")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_rollout_evidence_prevents_catalog_reactivation_and_insertion() {
|
||||
let codex_dir = temp_codex_dir("internal-catalog-regression");
|
||||
write_config(&codex_dir, "custom");
|
||||
let parent = "019f6000-0000-7000-8000-000000000900";
|
||||
let hidden = "019f6000-0000-7000-8000-000000000901";
|
||||
let missing = "019f6000-0000-7000-8000-000000000902";
|
||||
let catalog_only = "019f6000-0000-7000-8000-000000000903";
|
||||
let source_only = "019f6000-0000-7000-8000-000000000904";
|
||||
let parent_rollout = write_rollout(&codex_dir, parent, "openai");
|
||||
let state = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database_with_rollout(&state, parent, "openai", &parent_rollout);
|
||||
let conn = Connection::open(&state).expect("open regression index");
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE threads ADD COLUMN source TEXT;
|
||||
ALTER TABLE threads ADD COLUMN thread_source TEXT;
|
||||
ALTER TABLE threads ADD COLUMN parent_thread_id TEXT;
|
||||
ALTER TABLE threads ADD COLUMN cwd TEXT;",
|
||||
)
|
||||
.expect("extend source metadata");
|
||||
conn.execute("UPDATE threads SET title = ?1, source = 'cli', thread_source = 'user', cwd = '/tmp/project' WHERE id = ?2",
|
||||
("Review this error: The following is the Codex agent history whose request action you are assessing", parent))
|
||||
.expect("set genuine parent title");
|
||||
let mut internal_files = Vec::new();
|
||||
for (id, rollout_source, sqlite_source) in [
|
||||
(hidden, serde_json::json!({"internal": "guardian"}), "cli"),
|
||||
(
|
||||
missing,
|
||||
serde_json::json!({"subagent": {"review": null}}),
|
||||
"cli",
|
||||
),
|
||||
(catalog_only, serde_json::json!("cli"), "cli"),
|
||||
(source_only, serde_json::json!("cli"), "subagent_review"),
|
||||
] {
|
||||
let path = write_rollout(&codex_dir, id, "openai");
|
||||
let record = serde_json::json!({"type":"session_meta", "payload":{
|
||||
"id": id, "model_provider":"openai", "source":rollout_source, "cwd":"/tmp/private"
|
||||
}});
|
||||
fs::write(&path, format!("{record}\n")).expect("write source metadata");
|
||||
conn.execute("INSERT INTO threads (id, model_provider, title, rollout_path, source, thread_source, cwd)
|
||||
VALUES (?1, 'openai', 'codex-auto-review', ?2, ?3, NULL, '/tmp/private')",
|
||||
(id, path.display().to_string(), sqlite_source)).expect("insert internal source");
|
||||
internal_files.push((
|
||||
path.clone(),
|
||||
fs::read(&path).expect("snapshot internal rollout"),
|
||||
));
|
||||
}
|
||||
drop(conn);
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(
|
||||
&catalog,
|
||||
&[
|
||||
(parent, "openai"),
|
||||
(hidden, "openai"),
|
||||
(catalog_only, "openai"),
|
||||
],
|
||||
);
|
||||
let conn = Connection::open(&catalog).expect("open regression catalog");
|
||||
conn.execute(
|
||||
"UPDATE local_thread_catalog SET missing_candidate = 1 WHERE thread_id = ?1",
|
||||
[hidden],
|
||||
)
|
||||
.expect("keep review hidden");
|
||||
conn.execute("UPDATE local_thread_catalog SET source_kind = 'internal', thread_source = 'guardian_review', missing_candidate = 1 WHERE thread_id = ?1", [catalog_only])
|
||||
.expect("mark catalog-only internal source");
|
||||
drop(conn);
|
||||
let before_threads = rows_except_parent(&state, "threads", "id", parent);
|
||||
let before_catalog =
|
||||
rows_except_parent(&catalog, "local_thread_catalog", "thread_id", parent);
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan mixed sources");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert_eq!(status.mismatched_sessions, 1, "{status:?}");
|
||||
assert!(
|
||||
status
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| session.id == parent)
|
||||
.expect("parent preview")
|
||||
.needs_sync
|
||||
);
|
||||
for session in status
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|session| session.id != parent)
|
||||
{
|
||||
assert!(session.is_subagent, "internal preview {}", session.id);
|
||||
assert!(
|
||||
!session.needs_sync,
|
||||
"internal must not be pending {}",
|
||||
session.id
|
||||
);
|
||||
}
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("sync only parent");
|
||||
assert!(!result.status.needs_sync);
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert_eq!(thread_provider(&state, parent), "custom");
|
||||
assert_eq!(catalog_provider(&catalog, parent), "custom");
|
||||
assert_eq!(
|
||||
rows_except_parent(&state, "threads", "id", parent),
|
||||
before_threads
|
||||
);
|
||||
assert_eq!(
|
||||
rows_except_parent(&catalog, "local_thread_catalog", "thread_id", parent),
|
||||
before_catalog
|
||||
);
|
||||
assert!(catalog_provider_and_visibility(&catalog, missing).is_none());
|
||||
assert!(catalog_provider_and_visibility(&catalog, source_only).is_none());
|
||||
for (path, original) in internal_files {
|
||||
assert_eq!(
|
||||
fs::read(path).expect("read preserved internal rollout"),
|
||||
original
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(codex_dir).expect("remove regression fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoritative_user_rollout_is_not_hidden_by_an_internal_duplicate() {
|
||||
let codex_dir = temp_codex_dir("user-internal-duplicate");
|
||||
write_config(&codex_dir, "custom");
|
||||
let id = "019f6000-0000-7000-8000-000000000905";
|
||||
let active = codex_dir.join("sessions/rollout-active-name.jsonl");
|
||||
write_rollout_at(&active, id, "openai");
|
||||
let stale = write_subagent_rollout(&codex_dir, id, "old-parent", "openai");
|
||||
let stale_bytes = fs::read(&stale).expect("snapshot stale internal duplicate");
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, "openai", &active);
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[]);
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan authoritative user");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert_eq!(status.subagent_threads, 0);
|
||||
assert_eq!(status.mismatched_sessions, 1);
|
||||
assert!(!status.sessions[0].is_subagent);
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("sync authoritative user");
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert_eq!(thread_provider(&database, id), "custom");
|
||||
assert_eq!(catalog_provider(&catalog, id), "custom");
|
||||
assert_eq!(
|
||||
fs::read(stale).expect("read untouched internal duplicate"),
|
||||
stale_bytes
|
||||
);
|
||||
fs::remove_dir_all(codex_dir).expect("remove duplicate fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_provider_is_used_as_the_sync_target() {
|
||||
let codex_dir = temp_codex_dir("live-provider-target");
|
||||
@@ -773,7 +1380,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_standard_orphan_rollout_does_not_block_active_sessions() {
|
||||
fn malformed_standard_orphan_keeps_check_incomplete_without_mutation() {
|
||||
let codex_dir = temp_codex_dir("malformed-standard-orphan");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let active_id = "019f6000-0000-7000-8000-000000000502";
|
||||
@@ -783,26 +1390,30 @@ mod tests {
|
||||
active_id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
);
|
||||
write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
|
||||
fs::write(
|
||||
codex_dir.join(format!("sessions/rollout-test-{orphan_id}.jsonl")),
|
||||
b"\xff",
|
||||
)
|
||||
.expect("write invalid orphan rollout");
|
||||
let active = write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
|
||||
let active_before = fs::read(&active).unwrap();
|
||||
let database = codex_dir.join("state_5.sqlite");
|
||||
let database_before = fs::read(&database).unwrap();
|
||||
let orphan = codex_dir.join(format!("sessions/rollout-test-{orphan_id}.jsonl"));
|
||||
fs::write(&orphan, b"\xff").expect("write invalid orphan rollout");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan active sessions only");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(!status.scan_complete);
|
||||
assert!(!status.scan_failures.is_empty());
|
||||
assert_eq!(status.rollout_files, 2);
|
||||
assert_eq!(status.session_meta_count, 1);
|
||||
assert!(!status.needs_sync);
|
||||
assert_eq!(status.warnings.len(), 1);
|
||||
assert!(sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None).is_err());
|
||||
assert_eq!(fs::read(&active).unwrap(), active_before);
|
||||
assert_eq!(fs::read(&database).unwrap(), database_before);
|
||||
assert_eq!(fs::read(&orphan).unwrap(), b"\xff");
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_unreferenced_nonstandard_rollout_does_not_block_active_sessions() {
|
||||
fn malformed_unreferenced_rollout_keeps_check_incomplete_without_mutation() {
|
||||
let codex_dir = temp_codex_dir("malformed-unreferenced-rollout");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let active_id = "019f6000-0000-7000-8000-000000000504";
|
||||
@@ -811,19 +1422,23 @@ mod tests {
|
||||
active_id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
);
|
||||
write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
|
||||
fs::write(
|
||||
codex_dir.join("sessions/rollout-imported-orphan.jsonl"),
|
||||
b"\xff",
|
||||
)
|
||||
.expect("write invalid unreferenced rollout");
|
||||
let active = write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
|
||||
let active_before = fs::read(&active).unwrap();
|
||||
let database = codex_dir.join("state_5.sqlite");
|
||||
let database_before = fs::read(&database).unwrap();
|
||||
let orphan = codex_dir.join("sessions/rollout-imported-orphan.jsonl");
|
||||
fs::write(&orphan, b"\xff").expect("write invalid unreferenced rollout");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan referenced sessions only");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(!status.scan_complete);
|
||||
assert!(!status.scan_failures.is_empty());
|
||||
assert_eq!(status.rollout_files, 2);
|
||||
assert_eq!(status.session_meta_count, 1);
|
||||
assert_eq!(status.warnings.len(), 1);
|
||||
assert!(sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None).is_err());
|
||||
assert_eq!(fs::read(&active).unwrap(), active_before);
|
||||
assert_eq!(fs::read(&database).unwrap(), database_before);
|
||||
assert_eq!(fs::read(&orphan).unwrap(), b"\xff");
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
@@ -860,6 +1475,111 @@ mod tests {
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverted_rollout_sync_uses_stable_thread_id_and_keeps_internal_threads_hidden() {
|
||||
let codex_dir = temp_codex_dir("reverted-rollout-sync");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let parent = "019f6000-0000-7000-8000-000000000920";
|
||||
let parent_rollout_id = "019f6000-0000-7000-8000-000000000921";
|
||||
let internal = "019f6000-0000-7000-8000-000000000922";
|
||||
let internal_rollout_id = "019f6000-0000-7000-8000-000000000923";
|
||||
let parent_path = codex_dir.join(format!(
|
||||
"sessions/rollout-2026-09-17T13-10-16-{parent}_{parent_rollout_id}.jsonl"
|
||||
));
|
||||
write_rollout_at(&parent_path, parent, "openai");
|
||||
let internal_path = codex_dir.join(format!(
|
||||
"sessions/rollout-2026-09-17T13-10-17-{internal}_{internal_rollout_id}.jsonl"
|
||||
));
|
||||
let internal_metadata = serde_json::json!({"type":"session_meta","payload":{
|
||||
"id":internal, "model_provider":"openai", "source":{"internal":"guardian"}
|
||||
}});
|
||||
fs::write(&internal_path, format!("{internal_metadata}\n"))
|
||||
.expect("write reverted internal rollout");
|
||||
let internal_bytes = fs::read(&internal_path).expect("snapshot internal rollout");
|
||||
let database = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, parent, "openai", &parent_path);
|
||||
Connection::open(&database).expect("open reverted thread index").execute(
|
||||
"INSERT INTO threads (id, model_provider, title, rollout_path) VALUES (?1, 'openai', 'internal review', ?2)",
|
||||
(internal, internal_path.display().to_string()),
|
||||
).expect("insert internal thread without database source markers");
|
||||
let before_internal = rows_except_parent(&database, "threads", "id", parent);
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[]);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("check reverted rollouts");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(status
|
||||
.warnings
|
||||
.iter()
|
||||
.all(|warning| !warning.contains("线程 ID 不一致")));
|
||||
assert_eq!(status.mismatched_sessions, 1);
|
||||
assert_eq!(status.subagent_threads, 1);
|
||||
for preview in &status.sessions {
|
||||
assert_eq!(preview.is_subagent, preview.id == internal);
|
||||
assert_eq!(preview.needs_sync, preview.id == parent);
|
||||
}
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("synchronize reverted user rollout through transaction guards");
|
||||
assert!(result.status.scan_complete);
|
||||
assert!(!result.status.needs_sync);
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert_eq!(thread_provider(&database, parent), SHARED_SESSION_PROVIDER);
|
||||
assert_eq!(catalog_provider(&catalog, parent), SHARED_SESSION_PROVIDER);
|
||||
for excluded in [parent_rollout_id, internal, internal_rollout_id] {
|
||||
assert!(catalog_provider_and_visibility(&catalog, excluded).is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
rows_except_parent(&database, "threads", "id", parent),
|
||||
before_internal
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&internal_path).expect("read unchanged internal rollout"),
|
||||
internal_bytes
|
||||
);
|
||||
let record: serde_json::Value = serde_json::from_str(
|
||||
fs::read_to_string(&parent_path)
|
||||
.expect("read synchronized parent")
|
||||
.trim(),
|
||||
)
|
||||
.expect("parse synchronized parent metadata");
|
||||
assert_eq!(record["payload"]["id"], parent);
|
||||
assert_eq!(record["payload"]["model_provider"], SHARED_SESSION_PROVIDER);
|
||||
fs::remove_dir_all(codex_dir).expect("remove reverted rollout fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverted_rollout_cannot_be_indexed_by_its_distinct_rollout_id() {
|
||||
let codex_dir = temp_codex_dir("reverted-rollout-wrong-index-id");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let thread_id = "019f6000-0000-7000-8000-000000000924";
|
||||
let rollout_id = "019f6000-0000-7000-8000-000000000925";
|
||||
let path = codex_dir.join(format!(
|
||||
"sessions/rollout-2026-09-17T13-10-16-{thread_id}_{rollout_id}.jsonl"
|
||||
));
|
||||
write_rollout_at(&path, thread_id, "openai");
|
||||
let original = fs::read(&path).expect("snapshot original rollout");
|
||||
let database = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, rollout_id, "openai", &path);
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[]);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("check mismatched reverted rollout reference");
|
||||
assert!(!status.scan_complete);
|
||||
assert!(status
|
||||
.scan_failures
|
||||
.iter()
|
||||
.any(|failure| failure.contains("线程 ID 不一致")));
|
||||
sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect_err("rollout ID must not substitute for its stable thread ID");
|
||||
assert_eq!(thread_provider(&database, rollout_id), "openai");
|
||||
assert_eq!(fs::read(&path).expect("read protected rollout"), original);
|
||||
assert!(catalog_provider_and_visibility(&catalog, thread_id).is_none());
|
||||
assert!(catalog_provider_and_visibility(&catalog, rollout_id).is_none());
|
||||
fs::remove_dir_all(codex_dir).expect("remove invalid reverted reference fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_referenced_rollout_with_a_different_session_id_blocks_sync() {
|
||||
let codex_dir = temp_codex_dir("referenced-rollout-id-mismatch");
|
||||
@@ -1125,7 +1845,7 @@ mod tests {
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert!(result.updated_threads > 0);
|
||||
assert_eq!(thread_provider(&active, id), SHARED_SESSION_PROVIDER);
|
||||
assert_eq!(thread_provider(&legacy, id), SHARED_SESSION_PROVIDER);
|
||||
assert_eq!(thread_provider(&legacy, id), "openai");
|
||||
assert_eq!(catalog_provider(&catalog, id), SHARED_SESSION_PROVIDER);
|
||||
assert!(fs::read_to_string(rollout)
|
||||
.expect("read synchronized active user rollout")
|
||||
|
||||
@@ -2,7 +2,10 @@ use super::catalog::{
|
||||
apply_catalog_updates, catalog_columns, create_catalog_rollback_tables,
|
||||
restore_catalog_updates, CatalogRepairThread,
|
||||
};
|
||||
use super::storage::{apply_session_changes, restore_session_changes};
|
||||
use super::storage::{
|
||||
apply_session_changes_with_hook, hash_rollout_file, restore_session_changes,
|
||||
rollout_path_has_syncable_identity, rollout_text_is_internal, sqlite_subagent_thread_ids,
|
||||
};
|
||||
use super::types::{RolloutScan, SessionFileChange};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
@@ -34,6 +37,7 @@ impl SqliteUpdateCounts {
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct MutationJournal {
|
||||
applied_rollouts: Vec<SessionFileChange>,
|
||||
expected_rollout_hashes: HashMap<PathBuf, [u8; 32]>,
|
||||
sqlite_restore_attempts: Vec<SqliteRestoreAttempt>,
|
||||
}
|
||||
|
||||
@@ -182,6 +186,11 @@ fn apply_sqlite_updates(
|
||||
let mut sorted_thread_ids = syncable_thread_ids.iter().collect::<Vec<_>>();
|
||||
sorted_thread_ids.sort();
|
||||
for update in pending.iter_mut() {
|
||||
let internal_ids = if update.thread_columns.contains("id") {
|
||||
sqlite_subagent_thread_ids(&update.conn, &update.thread_columns)?
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("model_provider")
|
||||
{
|
||||
let archived_filter = if update.thread_columns.contains("archived") {
|
||||
@@ -203,6 +212,9 @@ fn apply_sqlite_updates(
|
||||
WHERE id = ?2 AND COALESCE(model_provider, '') <> ?1{archived_filter}"
|
||||
);
|
||||
for thread_id in &sorted_thread_ids {
|
||||
if internal_ids.contains(*thread_id) {
|
||||
continue;
|
||||
}
|
||||
update
|
||||
.conn
|
||||
.execute(&snapshot_sql, (target_provider, thread_id))
|
||||
@@ -234,7 +246,7 @@ fn apply_sqlite_updates(
|
||||
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1{archived_filter}"
|
||||
);
|
||||
for (thread_id, cwd) in &rollouts.cwd_by_thread_id {
|
||||
if !syncable_thread_ids.contains(thread_id) {
|
||||
if !syncable_thread_ids.contains(thread_id) || internal_ids.contains(thread_id) {
|
||||
continue;
|
||||
}
|
||||
update
|
||||
@@ -247,12 +259,16 @@ fn apply_sqlite_updates(
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
}
|
||||
let local_syncable_ids = syncable_thread_ids
|
||||
.difference(&internal_ids)
|
||||
.cloned()
|
||||
.collect();
|
||||
let catalog_counts = apply_catalog_updates(
|
||||
&update.conn,
|
||||
&update.catalog_columns,
|
||||
target_provider,
|
||||
catalog_sources,
|
||||
syncable_thread_ids,
|
||||
&local_syncable_ids,
|
||||
)?;
|
||||
update.counts.provider_rows += catalog_counts.provider_rows;
|
||||
update.counts.catalog_insert_rows += catalog_counts.inserted_rows;
|
||||
@@ -270,6 +286,7 @@ where
|
||||
{
|
||||
let mut updated = SqliteUpdateCounts::default();
|
||||
for index in 0..pending.len() {
|
||||
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
|
||||
let before_commit = sqlite_data_version(&pending[index].observer)?;
|
||||
journal.sqlite_restore_attempts.push(SqliteRestoreAttempt {
|
||||
path: pending[index].path.clone(),
|
||||
@@ -296,6 +313,9 @@ where
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
// The final commit hook can publish an external rollout update as well.
|
||||
// Returning success requires the complete candidate set to remain verified.
|
||||
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
@@ -402,15 +422,86 @@ pub(super) fn mutation_error(original: CodexxError, recovery_errors: Vec<String>
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum MutationPoint {
|
||||
BeforeSqliteLock,
|
||||
BeforeRolloutMutation,
|
||||
BeforeEachRolloutMutation(usize),
|
||||
AfterRolloutMutation,
|
||||
AfterSqliteCommit(usize),
|
||||
}
|
||||
|
||||
pub(super) struct MutationResult {
|
||||
pub(super) applied_rollouts: usize,
|
||||
pub(super) skipped_rollouts: Vec<PathBuf>,
|
||||
pub(super) sqlite_updates: SqliteUpdateCounts,
|
||||
}
|
||||
|
||||
// A file may be reclassified after scanning. In particular, the general file
|
||||
// writer skips concurrently changed rollouts; that must not leave their old IDs
|
||||
// eligible for a catalog insert or a provider update in this transaction.
|
||||
fn validate_rollout_classification(rollouts: &RolloutScan) -> Result<()> {
|
||||
let mut paths = rollouts.provider_candidate_paths.clone();
|
||||
for change in &rollouts.changes {
|
||||
let original_is_internal = if let Some(snapshot) = &change.streamed {
|
||||
!rollout_path_has_syncable_identity(&snapshot.original_path)?
|
||||
} else {
|
||||
rollout_text_is_internal(&change.original_text)
|
||||
};
|
||||
if original_is_internal {
|
||||
return Err(CodexxError::Config(
|
||||
"内部会话不能同步为普通会话。".to_string(),
|
||||
));
|
||||
}
|
||||
paths.insert(change.path.clone());
|
||||
}
|
||||
for path in paths {
|
||||
if !rollout_path_has_syncable_identity(&path)? {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话类型已变化,已停止同步;请重新检查会话:{}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn expected_rollout_hashes(rollouts: &RolloutScan) -> Result<HashMap<PathBuf, [u8; 32]>> {
|
||||
let mut expected = HashMap::new();
|
||||
for path in &rollouts.provider_candidate_paths {
|
||||
let hash = rollouts.verified_rollout_hashes.get(path).ok_or_else(|| {
|
||||
CodexxError::Config(format!(
|
||||
"缺少会话文件验证快照,已停止同步:{}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
expected.insert(path.clone(), *hash);
|
||||
}
|
||||
for change in &rollouts.changes {
|
||||
let original_hash = change.original_hash();
|
||||
if expected
|
||||
.get(&change.path)
|
||||
.is_some_and(|hash| *hash != original_hash)
|
||||
{
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话文件改写快照与扫描结果不一致,已停止同步:{}",
|
||||
change.path.display()
|
||||
)));
|
||||
}
|
||||
// A planned change already carries the scan's verified original snapshot.
|
||||
expected.insert(change.path.clone(), original_hash);
|
||||
}
|
||||
Ok(expected)
|
||||
}
|
||||
|
||||
fn validate_rollout_snapshots(expected: &HashMap<PathBuf, [u8; 32]>) -> Result<()> {
|
||||
for (path, expected_hash) in expected {
|
||||
if hash_rollout_file(path).map_err(|error| io_err(path, error))? != *expected_hash {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话文件已发生变化,已停止同步;请重新检查会话:{}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn execute_provider_sync_mutation<F>(
|
||||
rollouts: &RolloutScan,
|
||||
pending_sqlite: &mut [PendingSqliteUpdate],
|
||||
@@ -424,8 +515,35 @@ where
|
||||
F: FnMut(MutationPoint) -> Result<()>,
|
||||
{
|
||||
let result = (|| -> Result<MutationResult> {
|
||||
let (applied_rollouts, skipped_rollouts) = apply_session_changes(&rollouts.changes)?;
|
||||
if !rollouts.blocked_failures.is_empty() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话扫描未完成,已停止同步:{}",
|
||||
rollouts.blocked_failures.join(";")
|
||||
)));
|
||||
}
|
||||
journal.expected_rollout_hashes = expected_rollout_hashes(rollouts)?;
|
||||
hook(MutationPoint::BeforeRolloutMutation)?;
|
||||
validate_rollout_classification(rollouts)?;
|
||||
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
|
||||
let (applied_rollouts, skipped_rollouts) =
|
||||
apply_session_changes_with_hook(&rollouts.changes, |index| {
|
||||
hook(MutationPoint::BeforeEachRolloutMutation(index))
|
||||
})?;
|
||||
journal.applied_rollouts = applied_rollouts;
|
||||
for change in &journal.applied_rollouts {
|
||||
journal
|
||||
.expected_rollout_hashes
|
||||
.insert(change.path.clone(), change.next_hash());
|
||||
}
|
||||
if !skipped_rollouts.is_empty() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"有 {} 个会话文件被占用或已发生变化,已停止同步;请退出 Codex 后重新检查会话。",
|
||||
skipped_rollouts.len()
|
||||
)));
|
||||
}
|
||||
hook(MutationPoint::AfterRolloutMutation)?;
|
||||
validate_rollout_classification(rollouts)?;
|
||||
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
|
||||
apply_sqlite_updates(
|
||||
pending_sqlite,
|
||||
rollouts,
|
||||
@@ -436,7 +554,6 @@ where
|
||||
let sqlite_updates = commit_sqlite_updates(pending_sqlite, journal, hook)?;
|
||||
Ok(MutationResult {
|
||||
applied_rollouts: journal.applied_rollouts.len(),
|
||||
skipped_rollouts,
|
||||
sqlite_updates,
|
||||
})
|
||||
})();
|
||||
|
||||
@@ -2,13 +2,16 @@ use super::*;
|
||||
use crate::sessions::backup::provider_sync_backup_root;
|
||||
use crate::sessions::sync::sync_sessions_provider_with_hook;
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
const LARGE_ROLLOUT_TEST_BYTES: u64 = 33 * 1024 * 1024;
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
@@ -73,6 +76,633 @@ fn sqlite_quick_check(path: &Path) -> String {
|
||||
.expect("run quick check")
|
||||
}
|
||||
|
||||
struct RolloutFailureFixture {
|
||||
codex_dir: PathBuf,
|
||||
ids: [&'static str; 2],
|
||||
rollouts: [PathBuf; 2],
|
||||
originals: [Vec<u8>; 2],
|
||||
database: PathBuf,
|
||||
catalog: PathBuf,
|
||||
}
|
||||
|
||||
impl RolloutFailureFixture {
|
||||
fn new(name: &str) -> Self {
|
||||
let codex_dir = temp_dir(name);
|
||||
let ids = [
|
||||
"019f6000-0000-7000-8000-000000000991",
|
||||
"019f6000-0000-7000-8000-000000000992",
|
||||
];
|
||||
let rollouts = ids.map(|id| codex_dir.join(format!("sessions/rollout-test-{id}.jsonl")));
|
||||
let originals = std::array::from_fn(|index| write_rollout(&rollouts[index], ids[index]));
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, ids[0], &rollouts[0]);
|
||||
Connection::open(&database)
|
||||
.unwrap()
|
||||
.execute(
|
||||
"INSERT INTO threads (id, model_provider, rollout_path) VALUES (?1, 'openai', ?2)",
|
||||
(ids[1], rollouts[1].display().to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
fs::create_dir_all(catalog.parent().unwrap()).unwrap();
|
||||
let conn = Connection::open(&catalog).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE local_thread_catalog (
|
||||
host_id TEXT NOT NULL, thread_id TEXT NOT NULL, model_provider TEXT NOT NULL,
|
||||
PRIMARY KEY (host_id, thread_id));
|
||||
CREATE TABLE local_thread_catalog_metadata (
|
||||
id INTEGER PRIMARY KEY, catalog_revision INTEGER NOT NULL);
|
||||
INSERT INTO local_thread_catalog_metadata VALUES (1, 7);",
|
||||
)
|
||||
.unwrap();
|
||||
for id in ids {
|
||||
conn.execute(
|
||||
"INSERT INTO local_thread_catalog VALUES ('local', ?1, 'openai')",
|
||||
[id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
Self {
|
||||
codex_dir,
|
||||
ids,
|
||||
rollouts,
|
||||
originals,
|
||||
database,
|
||||
catalog,
|
||||
}
|
||||
}
|
||||
|
||||
fn scan(&self) -> RolloutScan {
|
||||
let changes = (0..2)
|
||||
.map(|index| {
|
||||
let original_text = String::from_utf8(self.originals[index].clone()).unwrap();
|
||||
SessionFileChange {
|
||||
path: self.rollouts[index].clone(),
|
||||
next_text: original_text.replace("\"openai\"", "\"custom\""),
|
||||
original_text,
|
||||
original_mtime: None,
|
||||
streamed: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
RolloutScan {
|
||||
changes,
|
||||
provider_candidate_paths: self.rollouts.iter().cloned().collect(),
|
||||
verified_rollout_hashes: (0..2)
|
||||
.map(|index| {
|
||||
(
|
||||
self.rollouts[index].clone(),
|
||||
Sha256::digest(&self.originals[index]).into(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
..RolloutScan::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn set_rollouts_to_target_provider(&mut self) {
|
||||
for index in 0..2 {
|
||||
self.originals[index] = String::from_utf8(self.originals[index].clone())
|
||||
.unwrap()
|
||||
.replace("\"openai\"", "\"custom\"")
|
||||
.into_bytes();
|
||||
fs::write(&self.rollouts[index], &self.originals[index]).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_provider(&self, id: &str) -> String {
|
||||
Connection::open(&self.catalog)
|
||||
.unwrap()
|
||||
.query_row(
|
||||
"SELECT model_provider FROM local_thread_catalog WHERE thread_id = ?1",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn assert_databases_unchanged(&self) {
|
||||
for id in self.ids {
|
||||
assert_eq!(thread_provider(&self.database, id), "openai");
|
||||
assert_eq!(self.catalog_provider(id), "openai");
|
||||
}
|
||||
let (rows, revision): (i64, i64) = Connection::open(&self.catalog)
|
||||
.unwrap()
|
||||
.query_row(
|
||||
"SELECT (SELECT COUNT(*) FROM local_thread_catalog),
|
||||
(SELECT catalog_revision FROM local_thread_catalog_metadata WHERE id = 1)",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((rows, revision), (2, 7));
|
||||
}
|
||||
|
||||
fn sync_with_hook<F>(&self, hook: F) -> Result<super::super::types::SessionSyncResult>
|
||||
where
|
||||
F: FnMut(MutationPoint) -> Result<()>,
|
||||
{
|
||||
sync_sessions_provider_with_hook(Some(self.codex_dir.display().to_string()), None, hook)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RolloutFailureFixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.codex_dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_rollout_scan_aborts_before_hooks_or_database_updates() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-blocked-scan");
|
||||
let mut rollouts = fixture.scan();
|
||||
rollouts
|
||||
.blocked_failures
|
||||
.push("会话文件读取失败,未完成扫描".to_string());
|
||||
let mut pending =
|
||||
prepare_sqlite_updates(&[fixture.database.clone(), fixture.catalog.clone()]).unwrap();
|
||||
let mut journal = MutationJournal::default();
|
||||
let mut hook_called = false;
|
||||
let error = execute_provider_sync_mutation(
|
||||
&rollouts,
|
||||
&mut pending,
|
||||
"custom",
|
||||
&HashMap::new(),
|
||||
&fixture.ids.map(str::to_string).into_iter().collect(),
|
||||
&mut journal,
|
||||
&mut |_| {
|
||||
hook_called = true;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.err()
|
||||
.expect("blocked scan must fail");
|
||||
assert!(error.to_string().contains("读取失败"));
|
||||
assert!(!hook_called);
|
||||
assert!(journal.applied_rollouts.is_empty());
|
||||
assert!(rollback_mutation(&journal, &mut pending).is_empty());
|
||||
fixture.assert_databases_unchanged();
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[index]).unwrap(),
|
||||
fixture.originals[index]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_rollout_change_is_rejected_before_file_or_database_updates() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-skip-rollback");
|
||||
let mut external = fixture.originals[1].clone();
|
||||
external.extend_from_slice(
|
||||
b"{\"type\":\"event_msg\",\"payload\":{\"message\":\"keep-new-data\"}}\n",
|
||||
);
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeRolloutMutation {
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("any skipped rollout must abort the full sync");
|
||||
assert!(error.to_string().contains("已停止同步"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_rollout_after_first_write_rolls_back_and_never_updates_indexes() {
|
||||
let fixture = RolloutFailureFixture::new("streamed-skip-after-write");
|
||||
let mut external = fixture.originals[1].clone();
|
||||
external.extend_from_slice(
|
||||
b"{\"type\":\"event_msg\",\"payload\":{\"message\":\"keep-external-data\"}}\n",
|
||||
);
|
||||
let mut first_write_observed = false;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeEachRolloutMutation(1) {
|
||||
first_write_observed = fs::read_to_string(&fixture.rollouts[0])
|
||||
.unwrap()
|
||||
.contains("custom");
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("a skipped second rollout must fail and roll back the first write");
|
||||
assert!(first_write_observed);
|
||||
assert!(error.to_string().contains("已停止同步"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_read_failure_after_scan_aborts_without_database_updates() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-read-failure");
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeRolloutMutation {
|
||||
fs::remove_file(&fixture.rollouts[1]).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("unreadable full rollout must fail the sync");
|
||||
assert!(error.to_string().contains(fixture.ids[1]), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert!(
|
||||
!fixture.rollouts[1].exists(),
|
||||
"external deletion must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rollout_atomic_write_failure_restores_earlier_files_without_database_updates() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut fixture = RolloutFailureFixture::new("streaming-write-failure");
|
||||
let blocked_parent = fixture.codex_dir.join("sessions/zz-write-blocked");
|
||||
fs::create_dir_all(&blocked_parent).unwrap();
|
||||
let second_path = blocked_parent.join(fixture.rollouts[1].file_name().unwrap());
|
||||
fs::rename(&fixture.rollouts[1], &second_path).unwrap();
|
||||
fixture.rollouts[1] = second_path;
|
||||
Connection::open(&fixture.database)
|
||||
.unwrap()
|
||||
.execute(
|
||||
"UPDATE threads SET rollout_path = ?1 WHERE id = ?2",
|
||||
(fixture.rollouts[1].display().to_string(), fixture.ids[1]),
|
||||
)
|
||||
.unwrap();
|
||||
let mut reached_file_mutation = false;
|
||||
let result = fixture.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeEachRolloutMutation(1) {
|
||||
reached_file_mutation = true;
|
||||
fs::set_permissions(&blocked_parent, fs::Permissions::from_mode(0o500)).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
// Always restore the temporary fixture permissions before assertions/drop.
|
||||
fs::set_permissions(&blocked_parent, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let error = result.expect_err("temporary rollout write permission failure must fail sync");
|
||||
assert!(
|
||||
reached_file_mutation,
|
||||
"failure must occur after scan and backup"
|
||||
);
|
||||
assert!(error.to_string().contains("IO error"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[index]).unwrap(),
|
||||
fixture.originals[index]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_provider_rewrite_after_mutation_aborts_without_database_or_catalog_changes() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-provider-race");
|
||||
let external = fixture.originals[1].clone();
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterRolloutMutation {
|
||||
assert!(fs::read_to_string(&fixture.rollouts[0])
|
||||
.unwrap()
|
||||
.contains("\"custom\""));
|
||||
assert!(fs::read_to_string(&fixture.rollouts[1])
|
||||
.unwrap()
|
||||
.contains("\"custom\""));
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("ordinary provider change after writes must fail sync");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_snapshot_is_rechecked_before_each_database_commit() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-between-commits");
|
||||
let mut first_committed = false;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterSqliteCommit(0) {
|
||||
first_committed = true;
|
||||
assert!(
|
||||
thread_provider(&fixture.database, fixture.ids[0]) == "custom"
|
||||
|| fixture.catalog_provider(fixture.ids[0]) == "custom"
|
||||
);
|
||||
fs::write(&fixture.rollouts[1], &fixture.originals[1]).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("new rollout data before the second commit must abort and restore the first");
|
||||
assert!(first_committed);
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[index]).unwrap(),
|
||||
fixture.originals[index]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_growth_after_scan_is_not_overwritten_or_reported_as_success() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-growth-before-mutation");
|
||||
let enlarged_len = LARGE_ROLLOUT_TEST_BYTES;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeRolloutMutation {
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&fixture.rollouts[1])
|
||||
.unwrap()
|
||||
.set_len(enlarged_len)
|
||||
.unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("concurrent growth must invalidate the verified snapshot");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
|
||||
enlarged_len
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_growth_after_file_writes_preserves_external_data_and_rolls_back_other_files() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-growth-after-mutation");
|
||||
let marker = b"keep-external-growth";
|
||||
let marker_offset = LARGE_ROLLOUT_TEST_BYTES;
|
||||
let enlarged_len = marker_offset + marker.len() as u64;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterRolloutMutation {
|
||||
let mut external = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&fixture.rollouts[1])
|
||||
.unwrap();
|
||||
external.set_len(enlarged_len).unwrap();
|
||||
external.seek(SeekFrom::Start(marker_offset)).unwrap();
|
||||
external.write_all(marker).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("growth after writes must prevent all database commits");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
|
||||
enlarged_len
|
||||
);
|
||||
let mut preserved = fs::File::open(&fixture.rollouts[1]).unwrap();
|
||||
preserved.seek(SeekFrom::Start(marker_offset)).unwrap();
|
||||
let mut restored_marker = vec![0; marker.len()];
|
||||
preserved.read_exact(&mut restored_marker).unwrap();
|
||||
assert_eq!(restored_marker, marker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_read_failure_after_writes_restores_other_files_and_preserves_external_content() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-read-failure-after-writes");
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterRolloutMutation {
|
||||
fs::remove_file(&fixture.rollouts[1]).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("full snapshot read failure after writes must abort the sync");
|
||||
assert!(error.to_string().contains(fixture.ids[1]), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert!(
|
||||
!fixture.rollouts[1].exists(),
|
||||
"external deletion must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidate_without_verified_hash_is_rejected_before_mutation() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-unverified-candidate");
|
||||
let mut rollouts = fixture.scan();
|
||||
rollouts.changes.clear();
|
||||
rollouts.verified_rollout_hashes.clear();
|
||||
let mut pending =
|
||||
prepare_sqlite_updates(&[fixture.database.clone(), fixture.catalog.clone()]).unwrap();
|
||||
let mut journal = MutationJournal::default();
|
||||
let mut hook_called = false;
|
||||
let error = execute_provider_sync_mutation(
|
||||
&rollouts,
|
||||
&mut pending,
|
||||
"custom",
|
||||
&HashMap::new(),
|
||||
&fixture.ids.map(str::to_string).into_iter().collect(),
|
||||
&mut journal,
|
||||
&mut |_| {
|
||||
hook_called = true;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.err()
|
||||
.expect("unverified candidate must not take a fresh mutable file as its baseline");
|
||||
assert!(
|
||||
error.to_string().contains("缺少会话文件验证快照"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(!hook_called);
|
||||
fixture.assert_databases_unchanged();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidates_normally_repair_databases_without_rewriting_rollouts() {
|
||||
let mut fixture = RolloutFailureFixture::new("bounded-matched-normal-repair");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let result = fixture
|
||||
.sync_with_hook(|_| Ok(()))
|
||||
.expect("matched verified logs permit database repair");
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert!(result.updated_threads > 0);
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
thread_provider(&fixture.database, fixture.ids[index]),
|
||||
"custom"
|
||||
);
|
||||
assert_eq!(fixture.catalog_provider(fixture.ids[index]), "custom");
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[index]).unwrap(),
|
||||
fixture.originals[index]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_unchanged_candidate_repairs_databases_without_rewriting_the_file() {
|
||||
let mut fixture = RolloutFailureFixture::new("streamed-large-matched-repair");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let event = b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"unchanged large history\"}}\n";
|
||||
let chunk = event.repeat(512);
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&fixture.rollouts[1])
|
||||
.unwrap();
|
||||
let mut original_len = file.metadata().unwrap().len();
|
||||
while original_len <= LARGE_ROLLOUT_TEST_BYTES {
|
||||
file.write_all(&chunk).unwrap();
|
||||
original_len += chunk.len() as u64;
|
||||
}
|
||||
file.sync_all().unwrap();
|
||||
drop(file);
|
||||
let original_hash = hash_rollout_file(&fixture.rollouts[1]).unwrap();
|
||||
|
||||
let result = fixture
|
||||
.sync_with_hook(|_| Ok(()))
|
||||
.expect("large matched files permit database repair");
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert!(result.updated_threads > 0);
|
||||
for id in fixture.ids {
|
||||
assert_eq!(thread_provider(&fixture.database, id), "custom");
|
||||
assert_eq!(fixture.catalog_provider(id), "custom");
|
||||
}
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
|
||||
original_len
|
||||
);
|
||||
assert_eq!(
|
||||
hash_rollout_file(&fixture.rollouts[1]).unwrap(),
|
||||
original_hash
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidate_provider_changes_before_or_after_mutation_block_database_repair() {
|
||||
for stage in [
|
||||
MutationPoint::BeforeRolloutMutation,
|
||||
MutationPoint::AfterRolloutMutation,
|
||||
] {
|
||||
let mut fixture = RolloutFailureFixture::new("bounded-matched-provider-race");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let external = String::from_utf8(fixture.originals[1].clone())
|
||||
.unwrap()
|
||||
.replace("\"custom\"", "\"openai\"")
|
||||
.into_bytes();
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == stage {
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("changed already-matched candidate must not permit database repair");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidate_growth_before_or_after_mutation_blocks_database_repair() {
|
||||
for stage in [
|
||||
MutationPoint::BeforeRolloutMutation,
|
||||
MutationPoint::AfterRolloutMutation,
|
||||
] {
|
||||
let mut fixture = RolloutFailureFixture::new("bounded-matched-growth-race");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let enlarged_len = LARGE_ROLLOUT_TEST_BYTES;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == stage {
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&fixture.rollouts[1])
|
||||
.unwrap()
|
||||
.set_len(enlarged_len)
|
||||
.unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("changed already-matched candidate must prevent database repair");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
|
||||
enlarged_len
|
||||
);
|
||||
let mut external = fs::File::open(&fixture.rollouts[1]).unwrap();
|
||||
let mut prefix = vec![0; fixture.originals[1].len()];
|
||||
external.read_exact(&mut prefix).unwrap();
|
||||
assert_eq!(prefix, fixture.originals[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidate_change_after_final_commit_rolls_back_databases_and_preserves_external_log() {
|
||||
let mut fixture = RolloutFailureFixture::new("bounded-matched-final-commit-race");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let external = String::from_utf8(fixture.originals[1].clone())
|
||||
.unwrap()
|
||||
.replace("\"custom\"", "\"openai\"")
|
||||
.into_bytes();
|
||||
let mut last_commit_observed = false;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterSqliteCommit(1) {
|
||||
last_commit_observed = true;
|
||||
assert_eq!(thread_provider(&fixture.database, fixture.ids[0]), "custom");
|
||||
assert_eq!(fixture.catalog_provider(fixture.ids[0]), "custom");
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("the final commit hook must not bypass the complete snapshot validation");
|
||||
assert!(last_commit_observed);
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_update_failure_rolls_back_every_open_database_and_jsonl() {
|
||||
let codex_dir = temp_dir("multi-sqlite-update-failure");
|
||||
@@ -452,3 +1082,89 @@ fn injected_failure_restores_sqlite_and_jsonl_without_touching_global_state() {
|
||||
drop(wal_guard);
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_reclassification_before_mutation_aborts_without_registering_or_rewriting() {
|
||||
for existing_provider in ["openai", "custom"] {
|
||||
let codex_dir = temp_dir("internal-before-mutation");
|
||||
let id = "019f6000-0000-7000-8000-000000000980";
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
let existing = String::from_utf8(write_rollout(&rollout, id))
|
||||
.expect("UTF-8 fixture")
|
||||
.replace("openai", existing_provider);
|
||||
fs::write(&rollout, existing).expect("set current rollout provider");
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, id, &rollout);
|
||||
let internal = format!(
|
||||
"{}\n",
|
||||
serde_json::json!({"type":"session_meta", "payload":{
|
||||
"id":id, "model_provider":existing_provider, "source":{"internal":"guardian"}
|
||||
}})
|
||||
);
|
||||
let error = sync_sessions_provider_with_hook(
|
||||
Some(codex_dir.display().to_string()),
|
||||
None,
|
||||
|point| {
|
||||
if point == MutationPoint::BeforeRolloutMutation {
|
||||
fs::write(&rollout, &internal).expect("reclassify rollout during sync");
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.expect_err("reclassification must abort sync");
|
||||
assert!(error.to_string().contains("会话类型已变化"), "{error}");
|
||||
assert_eq!(thread_provider(&database, id), "openai");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&rollout).expect("read internal rollout"),
|
||||
internal
|
||||
);
|
||||
fs::remove_dir_all(codex_dir).expect("remove race fixture");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_reclassification_after_file_writes_rolls_back_other_files_and_all_databases() {
|
||||
let codex_dir = temp_dir("internal-after-rollout-mutation");
|
||||
let parent = "019f6000-0000-7000-8000-000000000981";
|
||||
let child = "019f6000-0000-7000-8000-000000000982";
|
||||
let parent_rollout = codex_dir.join(format!("sessions/rollout-test-{parent}.jsonl"));
|
||||
let child_rollout = codex_dir.join(format!("sessions/rollout-test-{child}.jsonl"));
|
||||
let original_parent = write_rollout(&parent_rollout, parent);
|
||||
write_rollout(&child_rollout, child);
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, parent, &parent_rollout);
|
||||
Connection::open(&database)
|
||||
.expect("open fixture index")
|
||||
.execute(
|
||||
"INSERT INTO threads (id, model_provider, rollout_path) VALUES (?1, 'openai', ?2)",
|
||||
(child, child_rollout.display().to_string()),
|
||||
)
|
||||
.expect("insert second thread");
|
||||
let internal = format!(
|
||||
"{}\n",
|
||||
serde_json::json!({"type":"session_meta", "payload":{
|
||||
"id":child, "model_provider":"openai", "source":{"internal":"guardian"}
|
||||
}})
|
||||
);
|
||||
let error =
|
||||
sync_sessions_provider_with_hook(Some(codex_dir.display().to_string()), None, |point| {
|
||||
if point == MutationPoint::AfterRolloutMutation {
|
||||
fs::write(&child_rollout, &internal)
|
||||
.expect("publish internal metadata during sync");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("reclassification must prevent database commits");
|
||||
assert!(error.to_string().contains("会话类型已变化"), "{error}");
|
||||
assert_eq!(thread_provider(&database, parent), "openai");
|
||||
assert_eq!(thread_provider(&database, child), "openai");
|
||||
assert_eq!(
|
||||
fs::read(&parent_rollout).expect("read restored parent"),
|
||||
original_parent
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&child_rollout).expect("preserve newer internal source"),
|
||||
internal
|
||||
);
|
||||
fs::remove_dir_all(codex_dir).expect("remove rollback fixture");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -23,6 +25,10 @@ pub(crate) struct SessionPreview {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SessionSyncStatus {
|
||||
pub(crate) codex_dir: String,
|
||||
#[serde(default)]
|
||||
pub(crate) directory_identities: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub(crate) pin_scope_key: Option<String>,
|
||||
pub(crate) target_provider: String,
|
||||
pub(crate) rollout_files: usize,
|
||||
pub(crate) session_meta_count: usize,
|
||||
@@ -59,11 +65,21 @@ pub(crate) struct RolloutScan {
|
||||
pub(crate) mismatched_rollouts: usize,
|
||||
pub(crate) mismatched_session_meta: usize,
|
||||
pub(crate) changes: Vec<SessionFileChange>,
|
||||
pub(crate) provider_candidate_paths: HashSet<PathBuf>,
|
||||
/// SHA-256 of every completely validated candidate's original JSONL. Keep
|
||||
/// matched rollouts covered without retaining another copy of their text.
|
||||
pub(crate) verified_rollout_hashes: HashMap<PathBuf, [u8; 32]>,
|
||||
pub(crate) cwd_by_thread_id: HashMap<String, String>,
|
||||
pub(crate) thread_ids: HashSet<String>,
|
||||
/// Non-user threads identified from their own rollout metadata. Retained
|
||||
/// even when the rollout is excluded from provider synchronization.
|
||||
pub(crate) internal_thread_ids: HashSet<String>,
|
||||
pub(crate) mismatched_thread_ids: HashSet<String>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
pub(crate) scan_failures: Vec<String>,
|
||||
/// Incomplete user-rollout scans that must block Provider/index mutations,
|
||||
/// including unindexed files that would otherwise become only warnings.
|
||||
pub(crate) blocked_failures: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -72,6 +88,24 @@ pub(crate) struct SessionFileChange {
|
||||
pub(crate) original_text: String,
|
||||
pub(crate) next_text: String,
|
||||
pub(crate) original_mtime: Option<SystemTime>,
|
||||
// Production snapshots live on disk; inline text is retained for small fixtures.
|
||||
pub(super) streamed: Option<Arc<super::rollout_stream::StreamedSnapshot>>,
|
||||
}
|
||||
|
||||
impl SessionFileChange {
|
||||
pub(super) fn original_hash(&self) -> [u8; 32] {
|
||||
self.streamed.as_ref().map_or_else(
|
||||
|| Sha256::digest(self.original_text.as_bytes()).into(),
|
||||
|snapshot| snapshot.original_hash,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn next_hash(&self) -> [u8; 32] {
|
||||
self.streamed.as_ref().map_or_else(
|
||||
|| Sha256::digest(self.next_text.as_bytes()).into(),
|
||||
|snapshot| snapshot.next_hash,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
|
||||
@@ -201,6 +201,7 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
|
||||
let auth = auth_path(&codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let doc = parse_toml_document(&cfg, &text)?;
|
||||
let doc = crate::failover::direct_document(&codex_dir, &doc)?;
|
||||
let model = string_value(&doc, "model");
|
||||
let model_provider = string_value(&doc, "model_provider");
|
||||
let is_official_provider = document_is_official(&doc);
|
||||
@@ -235,7 +236,8 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
|
||||
)?;
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
|
||||
} else {
|
||||
let candidates = matching_saved_provider_ids_from_config(&text, &saved_providers);
|
||||
let candidates =
|
||||
matching_saved_provider_ids_from_config(&doc.to_string(), &saved_providers);
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
|
||||
};
|
||||
|
||||
|
||||
@@ -4571,7 +4571,8 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
let root_b = "019f6000-0000-7000-8000-000000000002";
|
||||
let child = "019f6000-0000-7000-8000-000000000003";
|
||||
let orphan_subagent = "019f6000-0000-7000-8000-000000000004";
|
||||
let forked_user = "019f6000-0000-7000-8000-000000000005";
|
||||
let mislabeled_child = "019f6000-0000-7000-8000-000000000005";
|
||||
let forked_user = "019f6000-0000-7000-8000-000000000006";
|
||||
let rollout = codex_dir.join("sessions/rollout.jsonl");
|
||||
seed_thread_database(
|
||||
&database,
|
||||
@@ -4579,6 +4580,7 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
(root_a, &rollout),
|
||||
(root_b, &rollout),
|
||||
(child, &rollout),
|
||||
(mislabeled_child, &rollout),
|
||||
(forked_user, &rollout),
|
||||
],
|
||||
Some((root_a, child)),
|
||||
@@ -4588,6 +4590,7 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
"ALTER TABLE threads ADD COLUMN title TEXT;
|
||||
ALTER TABLE threads ADD COLUMN source TEXT;
|
||||
ALTER TABLE threads ADD COLUMN thread_source TEXT;
|
||||
ALTER TABLE threads ADD COLUMN forked_from_id TEXT;
|
||||
UPDATE threads SET title = 'same title';",
|
||||
)
|
||||
.expect("extend thread schema");
|
||||
@@ -4598,14 +4601,19 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
.expect("mark child subagent");
|
||||
conn.execute(
|
||||
"UPDATE threads SET thread_source = 'user' WHERE id = ?1",
|
||||
[forked_user],
|
||||
[mislabeled_child],
|
||||
)
|
||||
.expect("mark forked user thread");
|
||||
.expect("seed generic user label on an actual spawned child");
|
||||
conn.execute(
|
||||
"INSERT INTO thread_spawn_edges (parent_thread_id, child_thread_id) VALUES (?1, ?2)",
|
||||
(root_a, mislabeled_child),
|
||||
)
|
||||
.expect("insert actual child spawn edge");
|
||||
conn.execute(
|
||||
"UPDATE threads SET thread_source = 'user', source = 'cli', forked_from_id = ?1 WHERE id = ?2",
|
||||
(root_a, forked_user),
|
||||
)
|
||||
.expect("insert user fork edge");
|
||||
.expect("mark a real user fork without a spawn edge");
|
||||
conn.execute(
|
||||
"INSERT INTO threads (id, model_provider, rollout_path, title, source)
|
||||
VALUES (?1, 'openai', ?2, 'same title', ?3)",
|
||||
@@ -4620,14 +4628,25 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
|
||||
let rollouts = scan_rollouts(&codex_dir, "openai").expect("scan rollouts");
|
||||
let scan = scan_sqlite(&codex_dir, &rollouts, "openai").expect("scan sqlite");
|
||||
assert_eq!(scan.sqlite_threads, 5);
|
||||
assert_eq!(scan.sqlite_threads, 6);
|
||||
assert_eq!(scan.top_level_threads, 3);
|
||||
assert_eq!(scan.subagent_threads, 2);
|
||||
assert_eq!(scan.subagent_threads, 3);
|
||||
assert!(scan.syncable_thread_ids.contains(forked_user));
|
||||
assert!(!scan.syncable_thread_ids.contains(mislabeled_child));
|
||||
|
||||
let (previews, warnings) =
|
||||
list_session_previews(&codex_dir, &rollouts, "openai", 50).expect("list previews");
|
||||
assert!(warnings.is_empty());
|
||||
assert_eq!(previews.iter().filter(|item| item.is_subagent).count(), 2);
|
||||
assert_eq!(previews.iter().filter(|item| item.is_subagent).count(), 3);
|
||||
for preview in &previews {
|
||||
assert_eq!(
|
||||
preview.is_subagent,
|
||||
[child, orphan_subagent, mislabeled_child].contains(&preview.id.as_str())
|
||||
);
|
||||
if preview.is_subagent {
|
||||
assert!(!preview.needs_sync);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
previews
|
||||
.into_iter()
|
||||
@@ -4638,6 +4657,7 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
root_b.to_string(),
|
||||
child.to_string(),
|
||||
orphan_subagent.to_string(),
|
||||
mislabeled_child.to_string(),
|
||||
forked_user.to_string(),
|
||||
])
|
||||
);
|
||||
|
||||
@@ -219,7 +219,7 @@ impl<'de> Visitor<'de> for UsageSourceSeed {
|
||||
|
||||
fn visit_str<E: serde::de::Error>(self, value: &str) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource {
|
||||
is_subagent: self.0 == 0 && value.trim().eq_ignore_ascii_case("subagent"),
|
||||
is_subagent: self.0 == 0 && crate::sessions::source_kind_is_internal(value),
|
||||
parent: (self.0 == 3).then(|| value.to_owned()),
|
||||
})
|
||||
}
|
||||
@@ -231,7 +231,10 @@ impl<'de> Visitor<'de> for UsageSourceSeed {
|
||||
const PATH: [&str; 3] = ["subagent", "thread_spawn", "parent_thread_id"];
|
||||
let mut source = UsageSource::default();
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
if PATH.get(self.0).copied() == Some(key.as_str()) {
|
||||
if self.0 == 0 && key == "internal" {
|
||||
map.next_value::<IgnoredAny>()?;
|
||||
source.is_subagent = true;
|
||||
} else if PATH.get(self.0).copied() == Some(key.as_str()) {
|
||||
let child = map.next_value_seed(UsageSourceSeed(self.0 + 1))?;
|
||||
source.is_subagent |= self.0 == 0;
|
||||
source.parent = child.parent;
|
||||
@@ -286,6 +289,7 @@ struct LogPayload {
|
||||
thread_id: Option<String>,
|
||||
forked_from_id: Option<String>,
|
||||
source: Option<UsageSource>,
|
||||
thread_source: Option<String>,
|
||||
cwd: Option<String>,
|
||||
model: Option<String>,
|
||||
info: Option<TokenInfo>,
|
||||
@@ -413,7 +417,11 @@ impl ParsedLog {
|
||||
self.is_subagent = payload
|
||||
.source
|
||||
.as_ref()
|
||||
.is_some_and(|source| source.is_subagent);
|
||||
.is_some_and(|source| source.is_subagent)
|
||||
|| payload
|
||||
.thread_source
|
||||
.as_deref()
|
||||
.is_some_and(crate::sessions::thread_source_is_internal);
|
||||
self.replays_parent = forked.is_some();
|
||||
self.spawn_parent = spawned.clone();
|
||||
self.invalid_spawn_parent = self.id.is_some() && self.id == self.spawn_parent;
|
||||
@@ -857,6 +865,7 @@ struct SessionLog {
|
||||
fn combine_files(
|
||||
cache: &DirectoryCache,
|
||||
coverage: &mut UsageCoverage,
|
||||
indexed_identities: &HashMap<String, crate::sessions::UsageThreadIdentity>,
|
||||
) -> BTreeMap<String, SessionLog> {
|
||||
let mut sessions: BTreeMap<String, SessionLog> = BTreeMap::new();
|
||||
let mut issues = ParseIssues::default();
|
||||
@@ -907,7 +916,38 @@ fn combine_files(
|
||||
}
|
||||
session.events.extend(parsed.events.iter().cloned());
|
||||
}
|
||||
for session in sessions.values_mut() {
|
||||
let mut canonical_files: Option<HashMap<PathBuf, &CachedFile>> = None;
|
||||
for (id, session) in &mut sessions {
|
||||
// Keep token records from every copy, but use the current database's
|
||||
// actual rollout for identity. A stale archived/internal copy with the
|
||||
// same ID must not hide a user conversation or supply a different parent.
|
||||
let authoritative = indexed_identities
|
||||
.get(id)
|
||||
.and_then(|identity| identity.rollout_path.as_ref())
|
||||
.and_then(|path| {
|
||||
cache.files.get(path).or_else(|| {
|
||||
let canonical = path.canonicalize().ok()?;
|
||||
canonical_files
|
||||
.get_or_insert_with(|| {
|
||||
cache
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|(path, entry)| {
|
||||
path.canonicalize().ok().map(|path| (path, entry))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(&canonical)
|
||||
.copied()
|
||||
})
|
||||
})
|
||||
.map(|entry| &entry.parsed)
|
||||
.filter(|parsed| parsed.meta_seen && parsed.id.as_ref() == Some(id));
|
||||
if let Some(parsed) = authoritative {
|
||||
session.is_subagent = parsed.is_subagent;
|
||||
session.spawn_parent.clone_from(&parsed.spawn_parent);
|
||||
session.invalid_spawn_parent = parsed.invalid_spawn_parent;
|
||||
}
|
||||
// The same rollout can coexist in sessions and archived_sessions, or
|
||||
// have overlapping resume segments. Merge its token records once.
|
||||
let mut seen = HashSet::new();
|
||||
@@ -1104,7 +1144,7 @@ impl Bucket {
|
||||
|
||||
fn conversation_identities(
|
||||
sessions: &BTreeMap<String, SessionLog>,
|
||||
codex_dir: &Path,
|
||||
indexed_identities: HashMap<String, crate::sessions::UsageThreadIdentity>,
|
||||
) -> HashMap<String, crate::sessions::UsageThreadIdentity> {
|
||||
let mut identities = sessions
|
||||
.iter()
|
||||
@@ -1116,18 +1156,24 @@ fn conversation_identities(
|
||||
classification_known: session.is_subagent,
|
||||
parent_id: session.spawn_parent.clone(),
|
||||
parent_conflict: session.invalid_spawn_parent,
|
||||
rollout_path: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
for (id, mut indexed) in crate::sessions::usage_thread_identities(codex_dir) {
|
||||
for (id, mut indexed) in indexed_identities {
|
||||
// Older indexes may have only id/title columns. Absence of a source
|
||||
// marker must not erase a positive subagent classification in the log.
|
||||
if !indexed.classification_known && identities.contains_key(&id) {
|
||||
continue;
|
||||
}
|
||||
// The session manager's current SQLite classification is authoritative.
|
||||
// A source-only subagent marker may still need its rollout's parent ID.
|
||||
// A generic user/default database marker must not promote a thread
|
||||
// whose own rollout explicitly identifies it as an internal task.
|
||||
if identities.get(&id).is_some_and(|log| log.is_subagent) {
|
||||
indexed.is_subagent = true;
|
||||
indexed.classification_known = true;
|
||||
}
|
||||
// A source-only internal marker may still need its rollout's parent ID.
|
||||
if indexed.is_subagent && indexed.parent_id.is_none() && !indexed.parent_conflict {
|
||||
if let Some(log) = identities.get(&id) {
|
||||
indexed.parent_id.clone_from(&log.parent_id);
|
||||
@@ -1207,8 +1253,9 @@ where
|
||||
.and_then(|date| midnight(&timezone, date))
|
||||
.map(|time| time.with_timezone(&Utc));
|
||||
let range_end = now.with_timezone(&Utc);
|
||||
let sessions = combine_files(cache, &mut coverage);
|
||||
let identities = conversation_identities(&sessions, codex_dir);
|
||||
let indexed_identities = crate::sessions::usage_thread_identities(codex_dir);
|
||||
let sessions = combine_files(cache, &mut coverage, &indexed_identities);
|
||||
let identities = conversation_identities(&sessions, indexed_identities);
|
||||
let mut total = Bucket::default();
|
||||
let mut days: BTreeMap<NaiveDate, Bucket> = BTreeMap::new();
|
||||
let mut models: BTreeMap<String, Bucket> = BTreeMap::new();
|
||||
@@ -2044,7 +2091,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let changed = fixture.stats("all", None);
|
||||
assert_eq!(changed.sessions[0].id, "child");
|
||||
assert_eq!(changed.sessions[0].id, "main");
|
||||
assert_eq!(changed.totals.total_tokens, 55);
|
||||
}
|
||||
|
||||
@@ -2089,7 +2136,95 @@ mod tests {
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fixture.stats("all", None).totals.session_count, 2);
|
||||
assert_eq!(fixture.stats("all", None).totals.session_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_and_memory_usage_stays_counted_without_creating_user_conversation_rows() {
|
||||
let mut fixture = Fixture::new();
|
||||
for (index, source) in [
|
||||
json!({"internal":"guardian"}),
|
||||
json!({"internal":"memory_consolidation"}),
|
||||
json!("internal_guardian"),
|
||||
json!("subagent_review"),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let id = format!("internal-{index}");
|
||||
let mut metadata = meta(&id, "2026-09-08T01:00:00Z", None);
|
||||
metadata["payload"]["source"] = source;
|
||||
fixture.write(
|
||||
&format!("sessions/{id}.jsonl"),
|
||||
&[
|
||||
metadata,
|
||||
context("codex-auto-review"),
|
||||
total("2026-09-08T01:00:01Z", 50, 5),
|
||||
],
|
||||
);
|
||||
}
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 220);
|
||||
assert_eq!(stats.totals.session_count, 0);
|
||||
assert!(stats.sessions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_rollout_controls_classification_while_duplicate_usage_is_preserved() {
|
||||
let mut fixture = Fixture::new();
|
||||
let current = [
|
||||
meta("main", "2026-09-08T01:00:00Z", None),
|
||||
context("model"),
|
||||
total("2026-09-08T01:00:01Z", 100, 10),
|
||||
];
|
||||
fixture.write("sessions/current.jsonl", ¤t);
|
||||
let mut stale = current.to_vec();
|
||||
stale[0]["payload"]["source"] = json!({"internal":"guardian"});
|
||||
stale.push(total("2026-09-08T01:00:02Z", 150, 15));
|
||||
fixture.write("archived_sessions/stale-copy.jsonl", &stale);
|
||||
let db = rusqlite::Connection::open(fixture.dir.join("state_5.sqlite")).unwrap();
|
||||
db.execute_batch("CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT, thread_source TEXT, source TEXT, rollout_path TEXT);
|
||||
INSERT INTO threads VALUES ('main', 'User conversation', 'user', 'cli', 'sessions/current.jsonl');").unwrap();
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 165);
|
||||
assert_eq!(stats.totals.session_count, 1);
|
||||
assert_eq!(stats.sessions.len(), 1);
|
||||
assert_eq!(stats.sessions[0].id, "main");
|
||||
assert_eq!(stats.sessions[0].totals.total_tokens, 165);
|
||||
|
||||
// An explicit internal source in the authoritative file still wins
|
||||
// over generic user/cli labels, even with a normal duplicate present.
|
||||
db.execute("UPDATE threads SET rollout_path = 'archived_sessions/stale-copy.jsonl' WHERE id = 'main'", []).unwrap();
|
||||
let internal = fixture.stats("all", None);
|
||||
assert_eq!(internal.totals.total_tokens, 165);
|
||||
assert_eq!(internal.totals.session_count, 0);
|
||||
assert!(internal.sessions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_metadata_thread_source_hides_internal_tasks_without_database_markers() {
|
||||
let mut fixture = Fixture::new();
|
||||
for (index, source) in ["guardian_review", "memory_consolidation"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let id = format!("internal-{index}");
|
||||
let mut metadata = meta(&id, "2026-09-08T01:00:00Z", None);
|
||||
metadata["payload"]["source"] = json!("cli");
|
||||
metadata["payload"]["thread_source"] = json!(source);
|
||||
fixture.write(
|
||||
&format!("sessions/{id}.jsonl"),
|
||||
&[
|
||||
metadata,
|
||||
context("model"),
|
||||
total("2026-09-08T01:00:01Z", 50, 5),
|
||||
],
|
||||
);
|
||||
}
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 110);
|
||||
assert_eq!(stats.totals.session_count, 0);
|
||||
assert!(stats.sessions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Windows staging/launching is deliberately separate from MSI service work.
|
||||
//! Only bytes already verified by tauri-plugin-updater reach this module.
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(super) struct StagedInstaller {
|
||||
dir: tempfile::TempDir,
|
||||
executable: PathBuf,
|
||||
}
|
||||
|
||||
fn is_windows_executable(bytes: &[u8]) -> bool {
|
||||
if bytes.len() < 64 || bytes.get(..2) != Some(b"MZ") {
|
||||
return false;
|
||||
}
|
||||
let offset = u32::from_le_bytes(bytes[60..64].try_into().unwrap()) as usize;
|
||||
offset >= 64
|
||||
&& offset
|
||||
.checked_add(4)
|
||||
.is_some_and(|end| bytes.get(offset..end) == Some(b"PE\0\0"))
|
||||
}
|
||||
|
||||
fn installer_arguments(pid: u32) -> [String; 4] {
|
||||
[
|
||||
"/P".into(),
|
||||
"/R".into(),
|
||||
"/UPDATE".into(),
|
||||
format!("/CODEXXPID={pid}"),
|
||||
]
|
||||
}
|
||||
|
||||
impl StagedInstaller {
|
||||
pub(super) fn create(verified_bytes: &[u8]) -> io::Result<Self> {
|
||||
if !is_windows_executable(verified_bytes) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"expected a verified Windows EXE installer",
|
||||
));
|
||||
}
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("Codex-X-update-")
|
||||
.tempdir()?;
|
||||
let executable = dir.path().join("Codex-X-setup.exe");
|
||||
let mut file = OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&executable)?;
|
||||
file.write_all(verified_bytes)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
Ok(Self { dir, executable })
|
||||
}
|
||||
|
||||
pub(super) fn launch(self, parent_pid: u32) -> io::Result<()> {
|
||||
if parent_pid == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"invalid parent PID",
|
||||
));
|
||||
}
|
||||
// No shell or interpolated command line: Unicode/space-containing user
|
||||
// paths are passed through Rust's native Windows argument quoting.
|
||||
let child = std::process::Command::new(&self.executable)
|
||||
.args(installer_arguments(parent_pid))
|
||||
.current_dir(self.dir.path())
|
||||
.spawn()?;
|
||||
// It owns its visible progress/errors from here, and waits for parent_pid
|
||||
// to exit before migrating or copying files. Never kill system installers.
|
||||
drop(child);
|
||||
let _ = self.dir.keep();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn staging_rejects_msi_html_truncated_and_invalid_pe_payloads() {
|
||||
for bytes in [
|
||||
b"\xd0\xcf\x11\xe0".as_slice(),
|
||||
b"<html>login</html>",
|
||||
b"MZ",
|
||||
&[0; 128],
|
||||
] {
|
||||
assert!(StagedInstaller::create(bytes).is_err());
|
||||
}
|
||||
let mut bytes = vec![0; 128];
|
||||
bytes[..2].copy_from_slice(b"MZ");
|
||||
bytes[60..64].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(StagedInstaller::create(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installer_staging_is_unique_and_cancel_cleans_only_its_directory() {
|
||||
let mut bytes = vec![0; 128];
|
||||
bytes[..2].copy_from_slice(b"MZ");
|
||||
bytes[60..64].copy_from_slice(&64u32.to_le_bytes());
|
||||
bytes[64..68].copy_from_slice(b"PE\0\0");
|
||||
let a = StagedInstaller::create(&bytes).unwrap();
|
||||
let b = StagedInstaller::create(&bytes).unwrap();
|
||||
assert_ne!(a.dir.path(), b.dir.path());
|
||||
assert_eq!(std::fs::read(&a.executable).unwrap(), bytes);
|
||||
let path = a.dir.path().to_owned();
|
||||
drop(a);
|
||||
assert!(!path.exists());
|
||||
assert!(b.executable.is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launcher_arguments_do_not_forward_user_cli_arguments_or_installer_paths() {
|
||||
assert_eq!(
|
||||
installer_arguments(123),
|
||||
["/P", "/R", "/UPDATE", "/CODEXXPID=123"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex-X",
|
||||
"version": "0.3.18",
|
||||
"version": "0.3.23",
|
||||
"identifier": "com.yynxxxxx.codexx",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -35,6 +35,9 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": true,
|
||||
"resources": {
|
||||
"../../../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md"
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
@@ -42,7 +45,18 @@
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"targets": "all"
|
||||
"targets": "all",
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "currentUser",
|
||||
"template": "nsis/installer.nsi",
|
||||
"languages": [
|
||||
"SimpChinese",
|
||||
"English"
|
||||
],
|
||||
"displayLanguageSelector": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
|
||||
+20
-261
@@ -1,270 +1,29 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
check as checkForTauriUpdate,
|
||||
type CheckOptions,
|
||||
type DownloadEvent,
|
||||
type DownloadOptions,
|
||||
type Update,
|
||||
} from "@tauri-apps/plugin-updater";
|
||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
import { check as checkForTauriUpdate } from "@tauri-apps/plugin-updater";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { AppUpdaterController, type AppUpdateEvent } from "./appUpdaterController";
|
||||
|
||||
export type AppUpdaterPhase =
|
||||
| "idle"
|
||||
| "checking"
|
||||
| "available"
|
||||
| "downloading"
|
||||
| "installing"
|
||||
| "ready"
|
||||
| "error";
|
||||
export { INITIAL_APP_UPDATER_STATE, isAppUpdateBusy } from "./appUpdaterController";
|
||||
export type { AppUpdaterPhase, AppUpdaterState, AppUpdaterFailure, AppUpdaterCheckResult, AppUpdaterCheckOptions } from "./appUpdaterController";
|
||||
|
||||
export type AppUpdaterFailure = "check" | "download" | "install" | "restart" | null;
|
||||
|
||||
export type AppUpdaterState = Readonly<{
|
||||
phase: AppUpdaterPhase;
|
||||
currentVersion: string | null;
|
||||
latestVersion: string | null;
|
||||
notes: string | null;
|
||||
publishedAt: string | null;
|
||||
downloadedBytes: number;
|
||||
totalBytes: number | null;
|
||||
failure: AppUpdaterFailure;
|
||||
}>;
|
||||
|
||||
export type AppUpdaterCheckResult = "available" | "up-to-date" | "error";
|
||||
|
||||
export type AppUpdaterCheckOptions = CheckOptions & {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export const INITIAL_APP_UPDATER_STATE: AppUpdaterState = {
|
||||
phase: "idle",
|
||||
currentVersion: null,
|
||||
latestVersion: null,
|
||||
notes: null,
|
||||
publishedAt: null,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
failure: null,
|
||||
};
|
||||
|
||||
type Listener = () => void;
|
||||
type RetryAction = "check" | "update" | "restart";
|
||||
|
||||
function normalizeByteCount(value: number | undefined): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
class AppUpdaterController {
|
||||
private state: AppUpdaterState = INITIAL_APP_UPDATER_STATE;
|
||||
private readonly listeners = new Set<Listener>();
|
||||
private update: Update | null = null;
|
||||
private checkPromise: Promise<AppUpdaterCheckResult> | null = null;
|
||||
private updatePromise: Promise<AppUpdaterPhase> | null = null;
|
||||
private restartPromise: Promise<AppUpdaterPhase> | null = null;
|
||||
private retryAction: RetryAction = "check";
|
||||
|
||||
readonly getSnapshot = (): AppUpdaterState => this.state;
|
||||
|
||||
readonly subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
readonly check = (options: AppUpdaterCheckOptions = {}): Promise<AppUpdaterCheckResult> => {
|
||||
if (this.checkPromise) return this.checkPromise;
|
||||
|
||||
if (this.isUpdateBusy()) {
|
||||
return Promise.resolve(this.update ? "available" : "error");
|
||||
}
|
||||
|
||||
if (this.update && !options.force) return Promise.resolve("available");
|
||||
|
||||
const { force: _force, ...pluginOptions } = options;
|
||||
this.checkPromise = this.performCheck(pluginOptions).finally(() => {
|
||||
this.checkPromise = null;
|
||||
export const appUpdater = new AppUpdaterController({
|
||||
check: checkForTauriUpdate,
|
||||
install: async (update, onEvent, options) => {
|
||||
const channel = new Channel<AppUpdateEvent>(onEvent);
|
||||
return invoke<{ restartRequired: boolean }>("install_app_update", {
|
||||
updateRid: update.rid,
|
||||
onEvent: channel,
|
||||
timeout: options?.timeout,
|
||||
headers: options?.headers ? Array.from(new Headers(options.headers).entries()) : undefined,
|
||||
});
|
||||
return this.checkPromise;
|
||||
};
|
||||
|
||||
readonly downloadAndInstall = (options?: DownloadOptions): Promise<AppUpdaterPhase> => {
|
||||
if (this.updatePromise) return this.updatePromise;
|
||||
if (this.state.phase === "ready") return Promise.resolve("ready");
|
||||
|
||||
if (!this.update) {
|
||||
this.retryAction = "check";
|
||||
this.setState({ phase: "error", failure: "check" });
|
||||
return Promise.resolve("error");
|
||||
}
|
||||
|
||||
this.updatePromise = this.performDownloadAndInstall(this.update, options).finally(() => {
|
||||
this.updatePromise = null;
|
||||
});
|
||||
return this.updatePromise;
|
||||
};
|
||||
|
||||
readonly retry = (): Promise<AppUpdaterCheckResult | AppUpdaterPhase> => {
|
||||
if (this.retryAction === "restart") return this.restart();
|
||||
if (this.retryAction === "update" && this.update) return this.downloadAndInstall();
|
||||
return this.check({ force: true });
|
||||
};
|
||||
|
||||
readonly restart = (): Promise<AppUpdaterPhase> => {
|
||||
if (this.restartPromise) return this.restartPromise;
|
||||
if (this.state.phase !== "ready" && this.state.failure !== "restart") {
|
||||
return Promise.resolve(this.state.phase);
|
||||
}
|
||||
|
||||
this.restartPromise = this.performRestart().finally(() => {
|
||||
this.restartPromise = null;
|
||||
});
|
||||
return this.restartPromise;
|
||||
};
|
||||
|
||||
private isUpdateBusy(): boolean {
|
||||
return this.state.phase === "downloading" || this.state.phase === "installing";
|
||||
}
|
||||
|
||||
private async performCheck(options: CheckOptions): Promise<AppUpdaterCheckResult> {
|
||||
this.retryAction = "check";
|
||||
this.setState({
|
||||
phase: "checking",
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
failure: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const nextUpdate = await checkForTauriUpdate(options);
|
||||
if (!nextUpdate) {
|
||||
await this.replaceUpdate(null);
|
||||
this.setState({
|
||||
phase: "idle",
|
||||
latestVersion: null,
|
||||
notes: null,
|
||||
publishedAt: null,
|
||||
failure: null,
|
||||
});
|
||||
return "up-to-date";
|
||||
}
|
||||
|
||||
await this.replaceUpdate(nextUpdate);
|
||||
this.retryAction = "update";
|
||||
this.setState({
|
||||
phase: "available",
|
||||
currentVersion: nextUpdate.currentVersion,
|
||||
latestVersion: nextUpdate.version,
|
||||
notes: nextUpdate.body?.trim() || null,
|
||||
publishedAt: nextUpdate.date || null,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
failure: null,
|
||||
});
|
||||
return "available";
|
||||
} catch {
|
||||
await this.replaceUpdate(null);
|
||||
this.setState({ phase: "error", failure: "check" });
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
private async performDownloadAndInstall(update: Update, options?: DownloadOptions): Promise<AppUpdaterPhase> {
|
||||
this.retryAction = "update";
|
||||
let downloadFinished = false;
|
||||
this.setState({
|
||||
phase: "downloading",
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
failure: null,
|
||||
});
|
||||
|
||||
try {
|
||||
await update.downloadAndInstall((event) => {
|
||||
if (event.event === "Finished") downloadFinished = true;
|
||||
this.handleDownloadEvent(event);
|
||||
}, options);
|
||||
this.setState({
|
||||
phase: "ready",
|
||||
downloadedBytes: this.state.totalBytes ?? this.state.downloadedBytes,
|
||||
failure: null,
|
||||
});
|
||||
return "ready";
|
||||
} catch {
|
||||
const failedDuringInstall = downloadFinished || this.state.phase === "installing";
|
||||
this.setState({
|
||||
phase: "error",
|
||||
failure: failedDuringInstall ? "install" : "download",
|
||||
});
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
private handleDownloadEvent(event: DownloadEvent): void {
|
||||
if (event.event === "Started") {
|
||||
this.setState({
|
||||
phase: "downloading",
|
||||
downloadedBytes: 0,
|
||||
totalBytes: normalizeByteCount(event.data.contentLength),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.event === "Progress") {
|
||||
const nextBytes = this.state.downloadedBytes + Math.max(0, event.data.chunkLength);
|
||||
this.setState({
|
||||
downloadedBytes: this.state.totalBytes === null
|
||||
? nextBytes
|
||||
: Math.min(nextBytes, this.state.totalBytes),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
phase: "installing",
|
||||
downloadedBytes: this.state.totalBytes ?? this.state.downloadedBytes,
|
||||
});
|
||||
}
|
||||
|
||||
private async performRestart(): Promise<AppUpdaterPhase> {
|
||||
this.retryAction = "restart";
|
||||
this.setState({ failure: null });
|
||||
try {
|
||||
await relaunch();
|
||||
return "ready";
|
||||
} catch {
|
||||
this.setState({ phase: "error", failure: "restart" });
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceUpdate(nextUpdate: Update | null): Promise<void> {
|
||||
const previousUpdate = this.update;
|
||||
this.update = nextUpdate;
|
||||
if (!previousUpdate || previousUpdate === nextUpdate) return;
|
||||
|
||||
try {
|
||||
await previousUpdate.close();
|
||||
} catch (error) {
|
||||
console.warn("Unable to release the previous updater resource", error);
|
||||
}
|
||||
}
|
||||
|
||||
private setState(patch: Partial<AppUpdaterState>): void {
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
|
||||
export const appUpdater = new AppUpdaterController();
|
||||
},
|
||||
restart: relaunch,
|
||||
setTimer: (callback, delay) => window.setTimeout(callback, delay),
|
||||
clearTimer: (timer) => window.clearTimeout(timer as number),
|
||||
});
|
||||
|
||||
export function useAppUpdater() {
|
||||
const state = useSyncExternalStore(appUpdater.subscribe, appUpdater.getSnapshot, appUpdater.getSnapshot);
|
||||
return {
|
||||
state,
|
||||
check: appUpdater.check,
|
||||
downloadAndInstall: appUpdater.downloadAndInstall,
|
||||
retry: appUpdater.retry,
|
||||
restart: appUpdater.restart,
|
||||
} as const;
|
||||
return { state, check: appUpdater.check, downloadAndInstall: appUpdater.downloadAndInstall, retry: appUpdater.retry, restart: appUpdater.restart } as const;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { CheckOptions, DownloadOptions } from "@tauri-apps/plugin-updater";
|
||||
|
||||
export type AppUpdaterPhase = "idle" | "checking" | "available" | "downloading" | "verifying" | "preparing" | "installing" | "handed-off" | "ready" | "error";
|
||||
export type AppUpdaterFailure = "check" | "download" | "verify" | "prepare" | "install" | "restart" | null;
|
||||
export type AppUpdaterState = Readonly<{
|
||||
phase: AppUpdaterPhase;
|
||||
currentVersion: string | null;
|
||||
latestVersion: string | null;
|
||||
notes: string | null;
|
||||
publishedAt: string | null;
|
||||
downloadedBytes: number;
|
||||
totalBytes: number | null;
|
||||
failure: AppUpdaterFailure;
|
||||
errorMessage: string | null;
|
||||
logPath: string | null;
|
||||
takingLonger: boolean;
|
||||
}>;
|
||||
export type AppUpdaterCheckResult = "available" | "up-to-date" | "error";
|
||||
export type AppUpdaterCheckOptions = CheckOptions & { force?: boolean };
|
||||
export type AppUpdateEvent =
|
||||
| { event: "Started"; data: { contentLength?: number } }
|
||||
| { event: "Progress"; data: { chunkLength: number } }
|
||||
| { event: "Verifying" | "Preparing" | "Installing" | "HandedOff" };
|
||||
export type CheckedAppUpdate = {
|
||||
rid: number;
|
||||
currentVersion: string;
|
||||
version: string;
|
||||
body?: string;
|
||||
date?: string;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
export type AppUpdaterDependencies = {
|
||||
check: (options: CheckOptions) => Promise<CheckedAppUpdate | null>;
|
||||
install: (update: CheckedAppUpdate, onEvent: (event: AppUpdateEvent) => void, options?: DownloadOptions) => Promise<{ restartRequired: boolean }>;
|
||||
restart: () => Promise<void>;
|
||||
setTimer: (callback: () => void, delay: number) => unknown;
|
||||
clearTimer: (timer: unknown) => void;
|
||||
};
|
||||
export const UPDATER_SLOW_NOTICE_MS = 30_000;
|
||||
export const INITIAL_APP_UPDATER_STATE: AppUpdaterState = {
|
||||
phase: "idle", currentVersion: null, latestVersion: null, notes: null, publishedAt: null,
|
||||
downloadedBytes: 0, totalBytes: null, failure: null, errorMessage: null, logPath: null, takingLonger: false,
|
||||
};
|
||||
export function isAppUpdateBusy(phase: AppUpdaterPhase): boolean {
|
||||
return ["downloading", "verifying", "preparing", "installing", "handed-off"].includes(phase);
|
||||
}
|
||||
function normalizeByteCount(value: number | undefined): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
function installationError(error: unknown, fallback: AppUpdaterFailure) {
|
||||
// Only the app's structured installation command supplies user-facing details.
|
||||
// Do not render arbitrary plugin/network errors, which can include private URLs.
|
||||
if (typeof error === "object" && error !== null && "stage" in error && "message" in error) {
|
||||
const data = error as Record<string, unknown>;
|
||||
const stage = ["download", "verify", "prepare", "install"].includes(String(data.stage))
|
||||
? data.stage as AppUpdaterFailure : fallback;
|
||||
return {
|
||||
failure: stage,
|
||||
errorMessage: typeof data.message === "string" ? data.message.slice(0, 1000) : null,
|
||||
logPath: typeof data.logPath === "string" ? data.logPath : null,
|
||||
};
|
||||
}
|
||||
return { failure: fallback, errorMessage: null, logPath: null };
|
||||
}
|
||||
|
||||
export class AppUpdaterController {
|
||||
private state: AppUpdaterState = INITIAL_APP_UPDATER_STATE;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private readonly dependencies: AppUpdaterDependencies;
|
||||
private update: CheckedAppUpdate | null = null;
|
||||
private checkPromise: Promise<AppUpdaterCheckResult> | null = null;
|
||||
private updatePromise: Promise<AppUpdaterPhase> | null = null;
|
||||
private restartPromise: Promise<AppUpdaterPhase> | null = null;
|
||||
private retryAction: "check" | "update" | "restart" = "check";
|
||||
private slowTimer: unknown = null;
|
||||
private attempt = 0;
|
||||
|
||||
constructor(dependencies: AppUpdaterDependencies) { this.dependencies = dependencies; }
|
||||
readonly getSnapshot = (): AppUpdaterState => this.state;
|
||||
readonly subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => { this.listeners.delete(listener); };
|
||||
};
|
||||
readonly check = (options: AppUpdaterCheckOptions = {}): Promise<AppUpdaterCheckResult> => {
|
||||
if (this.checkPromise) return this.checkPromise;
|
||||
if (isAppUpdateBusy(this.state.phase) || this.state.phase === "ready") {
|
||||
return Promise.resolve(this.update ? "available" : "error");
|
||||
}
|
||||
if (this.update && !options.force) return Promise.resolve("available");
|
||||
const { force: _force, ...pluginOptions } = options;
|
||||
this.checkPromise = this.performCheck(pluginOptions).finally(() => { this.checkPromise = null; });
|
||||
return this.checkPromise;
|
||||
};
|
||||
readonly downloadAndInstall = (options?: DownloadOptions): Promise<AppUpdaterPhase> => {
|
||||
if (this.updatePromise) return this.updatePromise;
|
||||
if (this.checkPromise) return Promise.resolve(this.state.phase);
|
||||
if (this.state.phase === "ready" || this.state.phase === "handed-off") return Promise.resolve(this.state.phase);
|
||||
if (!this.update) {
|
||||
this.retryAction = "check";
|
||||
this.setState({ phase: "error", failure: "check" });
|
||||
return Promise.resolve("error");
|
||||
}
|
||||
this.updatePromise = this.performDownloadAndInstall(this.update, options).finally(() => { this.updatePromise = null; });
|
||||
return this.updatePromise;
|
||||
};
|
||||
readonly retry = (): Promise<AppUpdaterCheckResult | AppUpdaterPhase> => {
|
||||
if (this.retryAction === "restart") return this.restart();
|
||||
if (this.retryAction === "update" && this.update) return this.downloadAndInstall();
|
||||
return this.check({ force: true });
|
||||
};
|
||||
readonly restart = (): Promise<AppUpdaterPhase> => {
|
||||
if (this.restartPromise) return this.restartPromise;
|
||||
if (this.state.phase !== "ready" && this.state.failure !== "restart") return Promise.resolve(this.state.phase);
|
||||
this.restartPromise = this.performRestart().finally(() => { this.restartPromise = null; });
|
||||
return this.restartPromise;
|
||||
};
|
||||
|
||||
private async performCheck(options: CheckOptions): Promise<AppUpdaterCheckResult> {
|
||||
this.retryAction = "check";
|
||||
this.setState({ phase: "checking", downloadedBytes: 0, totalBytes: null, failure: null, errorMessage: null, logPath: null, takingLonger: false });
|
||||
try {
|
||||
const nextUpdate = await this.dependencies.check(options);
|
||||
await this.replaceUpdate(nextUpdate);
|
||||
if (!nextUpdate) {
|
||||
this.setState({ phase: "idle", latestVersion: null, notes: null, publishedAt: null });
|
||||
return "up-to-date";
|
||||
}
|
||||
this.retryAction = "update";
|
||||
this.setState({ phase: "available", currentVersion: nextUpdate.currentVersion, latestVersion: nextUpdate.version,
|
||||
notes: nextUpdate.body?.trim() || null, publishedAt: nextUpdate.date || null });
|
||||
return "available";
|
||||
} catch {
|
||||
await this.replaceUpdate(null);
|
||||
this.setState({ phase: "error", failure: "check" });
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
private async performDownloadAndInstall(update: CheckedAppUpdate, options?: DownloadOptions): Promise<AppUpdaterPhase> {
|
||||
this.retryAction = "update";
|
||||
const attempt = ++this.attempt;
|
||||
let acceptingEvents = true;
|
||||
let handedOff = false;
|
||||
this.setState({ phase: "downloading", downloadedBytes: 0, totalBytes: null, failure: null, errorMessage: null, logPath: null, takingLonger: false });
|
||||
this.armSlowNotice();
|
||||
try {
|
||||
const result = await this.dependencies.install(update, (event) => {
|
||||
if (!acceptingEvents || attempt !== this.attempt || handedOff) return;
|
||||
if (event.event === "HandedOff") handedOff = true;
|
||||
this.handleInstallEvent(event);
|
||||
}, options);
|
||||
// Windows can leave this WebView alive briefly after handing off. The
|
||||
// installer has started, but its success is not yet known; never relaunch it.
|
||||
const phase = handedOff || !result.restartRequired ? "handed-off" : "ready";
|
||||
this.setState({ phase, failure: null, takingLonger: false });
|
||||
if (phase === "handed-off") this.armSlowNotice();
|
||||
return phase;
|
||||
} catch (error) {
|
||||
if (handedOff) return "handed-off";
|
||||
const fallback = this.state.phase === "verifying" ? "verify" : this.state.phase === "preparing" ? "prepare"
|
||||
: this.state.phase === "installing" ? "install" : "download";
|
||||
this.setState({ phase: "error", ...installationError(error, fallback), takingLonger: false });
|
||||
return "error";
|
||||
} finally {
|
||||
acceptingEvents = false;
|
||||
if (this.state.phase !== "handed-off") this.clearSlowNotice();
|
||||
}
|
||||
}
|
||||
private handleInstallEvent(event: AppUpdateEvent): void {
|
||||
if (event.event === "Started") {
|
||||
this.setState({ phase: "downloading", downloadedBytes: 0, totalBytes: normalizeByteCount(event.data.contentLength) });
|
||||
} else if (event.event === "Progress") {
|
||||
const bytes = normalizeByteCount(event.data.chunkLength);
|
||||
if (bytes === null) return;
|
||||
const next = this.state.downloadedBytes + bytes;
|
||||
this.setState({ downloadedBytes: this.state.totalBytes === null ? next : Math.min(next, this.state.totalBytes) });
|
||||
} else {
|
||||
const phase = { Verifying: "verifying", Preparing: "preparing", Installing: "installing", HandedOff: "handed-off" }[event.event] as AppUpdaterPhase;
|
||||
this.setState({ phase, downloadedBytes: this.state.totalBytes ?? this.state.downloadedBytes });
|
||||
}
|
||||
this.armSlowNotice();
|
||||
}
|
||||
private armSlowNotice(): void {
|
||||
this.clearSlowNotice();
|
||||
this.setState({ takingLonger: false });
|
||||
this.slowTimer = this.dependencies.setTimer(() => {
|
||||
this.slowTimer = null;
|
||||
if (isAppUpdateBusy(this.state.phase)) this.setState({ takingLonger: true });
|
||||
}, UPDATER_SLOW_NOTICE_MS);
|
||||
}
|
||||
private clearSlowNotice(): void {
|
||||
if (this.slowTimer !== null) this.dependencies.clearTimer(this.slowTimer);
|
||||
this.slowTimer = null;
|
||||
}
|
||||
private async performRestart(): Promise<AppUpdaterPhase> {
|
||||
this.retryAction = "restart";
|
||||
this.setState({ failure: null, errorMessage: null, logPath: null });
|
||||
try { await this.dependencies.restart(); this.setState({ phase: "ready" }); return "ready"; }
|
||||
catch { this.setState({ phase: "error", failure: "restart" }); return "error"; }
|
||||
}
|
||||
private async replaceUpdate(next: CheckedAppUpdate | null): Promise<void> {
|
||||
const previous = this.update;
|
||||
this.update = next;
|
||||
if (previous && previous !== next) {
|
||||
try { await previous.close(); }
|
||||
catch { console.warn("Unable to release the previous updater resource"); }
|
||||
}
|
||||
}
|
||||
private setState(patch: Partial<AppUpdaterState>): void {
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { INITIAL_APP_UPDATER_STATE, type AppUpdaterState } from "../appUpdater";
|
||||
import { INITIAL_APP_UPDATER_STATE, isAppUpdateBusy, type AppUpdaterState } from "../appUpdater";
|
||||
import type { Lang, StartupDiagnostics } from "../types";
|
||||
import { Button, ModalShell } from "./ui";
|
||||
|
||||
@@ -102,7 +102,7 @@ export function UpdateDialog({
|
||||
latestVersion: latestVersion ?? null,
|
||||
};
|
||||
const phase = updaterState.phase;
|
||||
const isBusy = phase === "downloading" || phase === "installing";
|
||||
const isBusy = isAppUpdateBusy(phase);
|
||||
const totalBytes = updaterState.totalBytes;
|
||||
const hasKnownProgress = totalBytes !== null && totalBytes > 0;
|
||||
const progress = totalBytes !== null && totalBytes > 0
|
||||
@@ -119,8 +119,14 @@ export function UpdateDialog({
|
||||
: "检测到新版本,可前往下载页获取对应平台的安装包。",
|
||||
downloadingTitle: "正在下载更新",
|
||||
downloadingDescription: "请保持 Codex-X 打开,下载完成后会自动安装。",
|
||||
installingTitle: "正在安装更新",
|
||||
installingDescription: "即将完成,请暂时不要关闭软件。",
|
||||
verifyingTitle: "正在验证安装包",
|
||||
verifyingDescription: "下载已完成,正在确认安装包完整可靠。",
|
||||
preparingTitle: "正在准备更新",
|
||||
preparingDescription: "正在保存状态并恢复连接配置,完成后会自动退出并开始安装。",
|
||||
installingTitle: "正在启动安装程序",
|
||||
installingDescription: "请稍候。如系统询问是否允许安装,请确认授权。",
|
||||
handedOffTitle: "安装程序已启动",
|
||||
handedOffDescription: "Codex-X 即将退出,请在安装窗口继续。首次升级旧版时,系统可能需要一次管理员授权。",
|
||||
readyTitle: "更新已准备好",
|
||||
readyDescription: "重新启动 Codex-X 即可使用新版本。",
|
||||
errorTitle: "更新没有完成",
|
||||
@@ -135,7 +141,16 @@ export function UpdateDialog({
|
||||
close: "关闭",
|
||||
updateNow: "立即更新",
|
||||
downloading: "正在下载",
|
||||
installing: "正在安装",
|
||||
verifying: "验证安装包",
|
||||
preparing: "准备退出",
|
||||
installing: "启动安装程序",
|
||||
handedOff: "等待安装程序",
|
||||
slowTitle: "这一步比平时慢一些",
|
||||
slowDescription: phase === "downloading"
|
||||
? "暂时没有收到新的下载数据,正在等待网络恢复。请勿重复点击更新。"
|
||||
: "仍在等待当前步骤完成。请查看是否有系统授权或安装窗口等待操作,不要重复启动安装。",
|
||||
detailLabel: "失败原因",
|
||||
logLabel: "安装日志",
|
||||
restart: "重新启动",
|
||||
retry: "重试",
|
||||
downloadPage: "打开下载页",
|
||||
@@ -150,8 +165,14 @@ export function UpdateDialog({
|
||||
: "A new version is available from the download page for your platform.",
|
||||
downloadingTitle: "Downloading update",
|
||||
downloadingDescription: "Keep Codex-X open. Installation starts automatically after download.",
|
||||
installingTitle: "Installing update",
|
||||
installingDescription: "Almost done. Please keep the app open.",
|
||||
verifyingTitle: "Verifying installer",
|
||||
verifyingDescription: "Download complete. Checking the installer before making changes.",
|
||||
preparingTitle: "Preparing update",
|
||||
preparingDescription: "Saving state and restoring connection settings before exiting to install.",
|
||||
installingTitle: "Starting installer",
|
||||
installingDescription: "Please wait. Approve the installation if your system asks for permission.",
|
||||
handedOffTitle: "Installer started",
|
||||
handedOffDescription: "Codex-X will exit. Continue in the installer window. Upgrading an older installation may ask for administrator permission once.",
|
||||
readyTitle: "Update is ready",
|
||||
readyDescription: "Restart Codex-X to use the new version.",
|
||||
errorTitle: "Update did not finish",
|
||||
@@ -166,39 +187,39 @@ export function UpdateDialog({
|
||||
close: "Close",
|
||||
updateNow: "Update now",
|
||||
downloading: "Downloading",
|
||||
installing: "Installing",
|
||||
verifying: "Verifying installer",
|
||||
preparing: "Preparing to exit",
|
||||
installing: "Starting installer",
|
||||
handedOff: "Waiting for installer",
|
||||
slowTitle: "Taking a little longer",
|
||||
slowDescription: phase === "downloading"
|
||||
? "Waiting for more download data. Please do not start another update."
|
||||
: "Still waiting for this step to finish. Check for a system permission or installer window requiring your attention. Do not start another installation.",
|
||||
detailLabel: "What went wrong",
|
||||
logLabel: "Installation log",
|
||||
restart: "Restart",
|
||||
retry: "Try again",
|
||||
downloadPage: "Open download page",
|
||||
releaseNotes: "What's new",
|
||||
};
|
||||
|
||||
const title = phase === "checking"
|
||||
? copy.checkingTitle
|
||||
: phase === "available"
|
||||
? copy.availableTitle
|
||||
: phase === "downloading"
|
||||
? copy.downloadingTitle
|
||||
: phase === "installing"
|
||||
? copy.installingTitle
|
||||
: phase === "ready"
|
||||
? copy.readyTitle
|
||||
: phase === "error"
|
||||
? copy.errorTitle
|
||||
: copy.idleTitle;
|
||||
const description = phase === "checking"
|
||||
? copy.checkingDescription
|
||||
: phase === "available"
|
||||
? copy.availableDescription
|
||||
: phase === "downloading"
|
||||
? copy.downloadingDescription
|
||||
: phase === "installing"
|
||||
? copy.installingDescription
|
||||
: phase === "ready"
|
||||
? copy.readyDescription
|
||||
: phase === "error"
|
||||
? copy.errorDescription
|
||||
: copy.idleDescription;
|
||||
const phaseCopy = {
|
||||
checking: [copy.checkingTitle, copy.checkingDescription],
|
||||
available: [copy.availableTitle, copy.availableDescription],
|
||||
downloading: [copy.downloadingTitle, copy.downloadingDescription],
|
||||
verifying: [copy.verifyingTitle, copy.verifyingDescription],
|
||||
preparing: [copy.preparingTitle, copy.preparingDescription],
|
||||
installing: [copy.installingTitle, copy.installingDescription],
|
||||
"handed-off": [copy.handedOffTitle, copy.handedOffDescription],
|
||||
ready: [copy.readyTitle, copy.readyDescription],
|
||||
error: [copy.errorTitle, copy.errorDescription],
|
||||
idle: [copy.idleTitle, copy.idleDescription],
|
||||
};
|
||||
const [title, description] = phaseCopy[phase];
|
||||
const progressLabel = phase === "downloading" ? copy.downloading
|
||||
: phase === "verifying" ? copy.verifying : phase === "preparing" ? copy.preparing
|
||||
: phase === "handed-off" ? copy.handedOff : phase === "ready" ? copy.restart : copy.installing;
|
||||
const indeterminate = isBusy && (phase !== "downloading" || progress === null);
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isBusy) onClose();
|
||||
@@ -239,7 +260,7 @@ export function UpdateDialog({
|
||||
: isBusy
|
||||
? (
|
||||
<Button disabled icon={<Loader2 className="spin" size={16} />}>
|
||||
{phase === "downloading" ? copy.downloading : copy.installing}
|
||||
{progressLabel}
|
||||
</Button>
|
||||
)
|
||||
: <Button variant="secondary" onClick={handleClose}>{copy.close}</Button>;
|
||||
@@ -272,10 +293,10 @@ export function UpdateDialog({
|
||||
<div><dt>{copy.latest}</dt><dd>{updaterState.latestVersion || latestVersion || "-"}</dd></div>
|
||||
</dl>
|
||||
|
||||
{(phase === "downloading" || phase === "installing" || phase === "ready") && (
|
||||
{(isBusy || phase === "ready") && (
|
||||
<div className="cx-update-progress" aria-live="polite">
|
||||
<div className="cx-update-progress-copy">
|
||||
<span>{phase === "downloading" ? copy.downloading : phase === "installing" ? copy.installing : copy.restart}</span>
|
||||
<span>{progressLabel}</span>
|
||||
<strong>
|
||||
{phase === "downloading"
|
||||
? hasKnownProgress
|
||||
@@ -289,18 +310,31 @@ export function UpdateDialog({
|
||||
</strong>
|
||||
</div>
|
||||
<div
|
||||
className={`cx-update-progress-track${progress === null && phase === "downloading" ? " cx-update-progress-track--indeterminate" : ""}`}
|
||||
className={`cx-update-progress-track${indeterminate ? " cx-update-progress-track--indeterminate" : ""}`}
|
||||
role="progressbar"
|
||||
aria-label={phase === "downloading" ? copy.downloading : copy.installing}
|
||||
aria-label={progressLabel}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={phase === "ready" ? 100 : progress ?? undefined}
|
||||
aria-valuenow={phase === "ready" ? 100 : indeterminate ? undefined : progress ?? undefined}
|
||||
>
|
||||
<span style={{ width: phase === "ready" || phase === "installing" ? "100%" : progress === null ? "38%" : `${progress}%` }} />
|
||||
<span style={{ width: phase === "ready" ? "100%" : indeterminate ? "38%" : `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updaterState.takingLonger && isBusy && (
|
||||
<section className="cx-update-notes" role="status">
|
||||
<strong>{copy.slowTitle}</strong>
|
||||
<p>{copy.slowDescription}</p>
|
||||
</section>
|
||||
)}
|
||||
{phase === "error" && updaterState.errorMessage && (
|
||||
<section className="cx-update-notes" role="alert">
|
||||
<strong>{copy.detailLabel}</strong>
|
||||
<p>{updaterState.errorMessage}</p>
|
||||
{updaterState.logPath && <p className="cx-update-log-path">{copy.logLabel}: {updaterState.logPath}</p>}
|
||||
</section>
|
||||
)}
|
||||
{updaterState.notes && phase !== "checking" && (
|
||||
<section className="cx-update-notes">
|
||||
<strong>{copy.releaseNotes}</strong>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { AppUpdaterPhase } from "../appUpdater";
|
||||
import { isAppUpdateBusy, type AppUpdaterPhase } from "../appUpdater";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
export type AppLanguage = "zh" | "en";
|
||||
@@ -102,11 +102,10 @@ export function AppShell({
|
||||
? (lang === "zh" ? "切换为浅色模式" : "Switch to light mode")
|
||||
: (lang === "zh" ? "切换为深色模式" : "Switch to dark mode");
|
||||
const ThemeIcon = theme === "dark" ? Moon : Sun;
|
||||
const updateActionState = updatePhase === "downloading"
|
||||
|| updatePhase === "installing"
|
||||
const updateActionState = isAppUpdateBusy(updatePhase)
|
||||
|| updatePhase === "ready"
|
||||
|| updatePhase === "available"
|
||||
? updatePhase
|
||||
? (isAppUpdateBusy(updatePhase) && updatePhase !== "downloading" ? "installing" : updatePhase)
|
||||
: hasUpdate
|
||||
? "available"
|
||||
: null;
|
||||
|
||||
@@ -11,6 +11,7 @@ function getCopy(lang: Language) {
|
||||
return lang === "zh" ? {
|
||||
title: "模型映射", optional: "可选", subtitle: "让 Codex 的模型菜单显示这个供应商实际提供的模型。",
|
||||
hint: "适用于支持 Responses 接口的模型。保存并启用后,重启 Codex 更新模型菜单。已有会话会保留原来的模型选择。",
|
||||
capabilities: "模型菜单允许文本与图片输入;实际能力由供应商接口决定。Ultra 还需在 Codex 设置中开启「模型选择器滑块中的 Ultra 选项」。",
|
||||
displayName: "菜单显示名称", displayPlaceholder: "留空使用模型 ID", model: "实际模型 ID", modelPlaceholder: "填写供应商提供的模型 ID",
|
||||
context: "上下文窗口", contextPlaceholder: "可选,如 128000", contextHint: "单位为 Token;留空使用默认值,最大 10,000,000。",
|
||||
addCurrent: "添加当前模型", importModels: "导入已获取模型", addRow: "添加空行", remove: "删除映射",
|
||||
@@ -25,6 +26,7 @@ function getCopy(lang: Language) {
|
||||
} : {
|
||||
title: "Model mappings", optional: "Optional", subtitle: "Show this provider’s actual models in the Codex model menu.",
|
||||
hint: "For models that support the Responses API. Save and enable this provider, then restart Codex to update its model menu. Existing conversations keep their selected model.",
|
||||
capabilities: "The menu allows text and image input; actual support depends on the provider API. To show Ultra, also enable the Ultra option in the model picker slider in Codex settings.",
|
||||
displayName: "Menu display name", displayPlaceholder: "Defaults to the model ID", model: "Actual model ID", modelPlaceholder: "Enter the provider’s model ID",
|
||||
context: "Context window", contextPlaceholder: "Optional, e.g. 128000", contextHint: "In tokens. Leave blank for the default; maximum 10,000,000.",
|
||||
addCurrent: "Add current model", importModels: "Import fetched models", addRow: "Add row", remove: "Remove mapping",
|
||||
@@ -136,6 +138,7 @@ export function ProviderModelMappings({ lang, rows, currentModel, availableModel
|
||||
<summary><ListFilter size={17} aria-hidden="true" /><div><strong>{copy.title}<span>{copy.optional}</span></strong><p>{copy.subtitle}</p></div><span className={`cx-provider-mappings-count${!validation.valid ? " cx-provider-mappings-count--invalid" : ""}`} title={copy.limit}>{validation.size} / {MAX_MAPPINGS}</span><ChevronDown size={16} className="cx-provider-mappings-chevron" aria-hidden="true" /></summary>
|
||||
<div className="cx-provider-mappings-body">
|
||||
<p className="cx-provider-mappings-hint">{copy.hint}</p>
|
||||
<p className="cx-provider-mappings-hint">{copy.capabilities}</p>
|
||||
<div className="cx-provider-mappings-toolbar">
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={() => add(current)} disabled={disabled || !canAddCurrent} title={currentIncluded ? copy.currentExists : !current ? copy.noCurrent : hasInvalidModelId(current) ? copy.invalidDefault : !canAddCurrent ? copy.limit : undefined}><Plus size={14} aria-hidden="true" />{copy.addCurrent}</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={importModels} disabled={disabled || !canImport} title={!availableModels.length ? copy.fetchFirst : !candidates.length ? copy.allImported : !canImport ? copy.limit : undefined}><Download size={14} aria-hidden="true" />{copy.importModels}</button>
|
||||
|
||||
+205
-33
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
SessionManagementPage,
|
||||
@@ -21,9 +22,9 @@ import {
|
||||
} from "./components/AppDialogs";
|
||||
import { PageTransition } from "./components/PageTransition";
|
||||
import { cx } from "./components/ui";
|
||||
import { appUpdater, useAppUpdater } from "./appUpdater";
|
||||
import { appUpdater, isAppUpdateBusy, useAppUpdater } from "./appUpdater";
|
||||
import { providerProfilesMatch, type ProviderProfile } from "./providerProfiles";
|
||||
import { orderProviderRows } from "./providerRowOrder";
|
||||
import { applyProviderRowOrder, orderProviderRows } from "./providerRowOrder";
|
||||
import { createPresetProvider, getProviderPreset, getProviderPresetVariant } from "./providerPresets";
|
||||
import { validateProviderModelMappings } from "./components/ProviderModelMappings";
|
||||
import { createOfficialProfileMonitor } from "./officialProfileMonitor";
|
||||
@@ -754,9 +755,11 @@ function App() {
|
||||
const [editingPromptId, setEditingPromptId] = React.useState<string | null>(null);
|
||||
const [editingBuiltinPrompt, setEditingBuiltinPrompt] = React.useState<BuiltinPromptDetail | null>(null);
|
||||
const [savedProviders, setSavedProviders] = React.useState<SavedProvider[]>([]);
|
||||
const [providerOrder, setProviderOrder] = React.useState<{ directory: string; order: string[] }>({ directory: "", order: [] });
|
||||
const [officialProfiles, setOfficialProfiles] = React.useState<OfficialProfileSummary[]>([]);
|
||||
const [editingOfficialProfileId, setEditingOfficialProfileId] = React.useState<string | null>(DEFAULT_OFFICIAL_PROFILE_ID);
|
||||
const [creatingProvider, setCreatingProvider] = React.useState(false);
|
||||
const [providerCreationBase, setProviderCreationBase] = React.useState("");
|
||||
const [selectedPresetId, setSelectedPresetId] = React.useState("custom");
|
||||
const [selectedPresetVariantId, setSelectedPresetVariantId] = React.useState("");
|
||||
const [officialAuthDirty, setOfficialAuthDirty] = React.useState(false);
|
||||
@@ -797,6 +800,7 @@ function App() {
|
||||
const [providerForm, setProviderForm] = React.useState<SavedProvider>(defaultProviderForm);
|
||||
const [providerTomlDraft, setProviderTomlDraft] = React.useState("");
|
||||
const [providerTomlDirty, setProviderTomlDirty] = React.useState(false);
|
||||
const [providerCommonConfigDirty, setProviderCommonConfigDirty] = React.useState(false);
|
||||
const [providerDraftRefreshToken, setProviderDraftRefreshToken] = React.useState(0);
|
||||
const [providerApiKeyVisible, setProviderApiKeyVisible] = React.useState(false);
|
||||
const [providerTestingId, setProviderTestingId] = React.useState("");
|
||||
@@ -825,6 +829,7 @@ function App() {
|
||||
const providerTomlEditorRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
const providerModelsRequestRef = React.useRef(0);
|
||||
const providerDraftRequestRef = React.useRef(0);
|
||||
const providerCreationRequestRef = React.useRef(0);
|
||||
const officialDraftRequestRef = React.useRef(0);
|
||||
const officialProfilesRequestRef = React.useRef(0);
|
||||
const officialProfilesLiveRef = React.useRef(officialProfiles);
|
||||
@@ -832,6 +837,7 @@ function App() {
|
||||
const officialMonitorReadyRef = React.useRef(false);
|
||||
officialMonitorReadyRef.current = Boolean(state) && !refreshing;
|
||||
const savedProvidersRequestRef = React.useRef(0);
|
||||
const providerOrderRequestRef = React.useRef(0);
|
||||
const loadingGenerationRef = React.useRef(0);
|
||||
const loadingTokensRef = React.useRef(new Set<number>());
|
||||
const actionBusyGenerationRef = React.useRef(0);
|
||||
@@ -853,6 +859,11 @@ function App() {
|
||||
const skillsMcpAutoLoadAttemptedRef = React.useRef("");
|
||||
const skillsMcpRequestRef = React.useRef(0);
|
||||
const activeConfigDirKeyRef = React.useRef("");
|
||||
const routingWakeRef = React.useRef<() => void>(() => {});
|
||||
const routingRequestRefreshRef = React.useRef<() => void>(() => {});
|
||||
const routingUiRef = React.useRef({ ready: false, editing: false });
|
||||
routingUiRef.current = { ready: Boolean(state) && !refreshing, editing: providerMode !== "list" };
|
||||
|
||||
const themeTransitionTimerRef = React.useRef<number | null>(null);
|
||||
const providerTomlPreview = React.useMemo(() => buildProviderTomlPreview(providerForm), [providerForm]);
|
||||
const providerAuthPreview = React.useMemo(() => buildProviderAuthPreview(providerForm), [providerForm]);
|
||||
@@ -924,10 +935,24 @@ function App() {
|
||||
savedProvidersRequestRef.current += 1;
|
||||
setSavedProviders(providers);
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
const directory = state?.codexDir || "";
|
||||
const request = ++providerOrderRequestRef.current;
|
||||
if (!directory) { setProviderOrder({ directory: "", order: [] }); return; }
|
||||
void invoke<string[]>("get_provider_order", { configDir: directory }).then((order) => {
|
||||
if (request === providerOrderRequestRef.current) setProviderOrder({ directory, order });
|
||||
}).catch((nextError) => {
|
||||
if (request !== providerOrderRequestRef.current) return;
|
||||
setProviderOrder({ directory, order: [] });
|
||||
setError(String(nextError));
|
||||
});
|
||||
}, [state?.codexDir]);
|
||||
const currentInstructionId = instructionIdFromPath(state?.instructionFile, instructionTemplates);
|
||||
const releaseStatusLabel = React.useMemo(() => {
|
||||
if (updater.state.phase === "downloading") return lang === "zh" ? "下载中" : "Downloading";
|
||||
if (updater.state.phase === "installing") return lang === "zh" ? "安装中" : "Installing";
|
||||
if (updater.state.phase === "verifying") return lang === "zh" ? "验证中" : "Verifying";
|
||||
if (updater.state.phase === "preparing") return lang === "zh" ? "准备更新" : "Preparing update";
|
||||
if (updater.state.phase === "installing" || updater.state.phase === "handed-off") return lang === "zh" ? "安装中" : "Installing";
|
||||
if (updater.state.phase === "ready") return lang === "zh" ? "等待重启" : "Restart required";
|
||||
if (releaseInfo.status === "checking") return lang === "zh" ? "检查中" : "Checking";
|
||||
if (releaseInfo.status === "error") return lang === "zh" ? "失败" : "Failed";
|
||||
@@ -1164,10 +1189,11 @@ function App() {
|
||||
const localRows = React.useMemo(() => {
|
||||
return savedProviders.map((p) => ({
|
||||
...p,
|
||||
model: effectiveActiveProviderId === p.id ? state?.model || p.model : p.model,
|
||||
source: "local" as const,
|
||||
isCurrent: effectiveActiveProviderId === p.id,
|
||||
}));
|
||||
}, [effectiveActiveProviderId, savedProviders]);
|
||||
}, [effectiveActiveProviderId, savedProviders, state?.model]);
|
||||
|
||||
const currentOfficialProfileId = state?.isOfficialProvider
|
||||
? state.activeOfficialProfileId || DEFAULT_OFFICIAL_PROFILE_ID
|
||||
@@ -1190,7 +1216,7 @@ function App() {
|
||||
source: "official",
|
||||
providerName: profile.providerName,
|
||||
baseUrl: "https://chatgpt.com/codex",
|
||||
model: profile.model || "official",
|
||||
model: profile.id === currentOfficialProfileId ? profile.model || state?.model || "" : profile.model || "",
|
||||
apiKey: "",
|
||||
wireApi: "official",
|
||||
requiresOpenaiAuth: true,
|
||||
@@ -1203,7 +1229,7 @@ function App() {
|
||||
});
|
||||
const rows = orderProviderRows(officialRow(defaultProfile), detectedRows, localRows);
|
||||
return [rows[0], ...officialProfiles.filter((profile) => !profile.isDefault).map(officialRow), ...rows.slice(1)];
|
||||
}, [currentOfficialProfileId, detectedRows, lang, localRows, officialProfiles, state?.officialAuthAvailable]);
|
||||
}, [currentOfficialProfileId, detectedRows, lang, localRows, officialProfiles, state?.officialAuthAvailable, state?.model]);
|
||||
|
||||
const findLocalProviderForRow = React.useCallback((row: ProviderRow) => {
|
||||
if (row.source === "official") return undefined;
|
||||
@@ -1230,7 +1256,8 @@ function App() {
|
||||
};
|
||||
}, [findLocalProviderForRow, state?.configText]);
|
||||
|
||||
const providerPageRows = React.useMemo<ProviderRow[]>(() => providerRows.map((row) => {
|
||||
const providerPageRows = React.useMemo<ProviderRow[]>(() => applyProviderRowOrder(providerRows,
|
||||
providerOrder.directory === state?.codexDir ? providerOrder.order : []).map((row) => {
|
||||
const local = findLocalProviderForRow(row);
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -1238,6 +1265,7 @@ function App() {
|
||||
providerName: row.providerName,
|
||||
baseUrl: row.baseUrl,
|
||||
model: row.model,
|
||||
modelDisplayName: local?.modelMappings?.find((mapping) => mapping.model === row.model)?.displayName,
|
||||
apiKey: row.apiKey,
|
||||
wireApi: row.wireApi,
|
||||
requiresOpenaiAuth: row.requiresOpenaiAuth,
|
||||
@@ -1255,7 +1283,32 @@ function App() {
|
||||
testable: row.source !== "official",
|
||||
testingKey: `${row.source}-${row.id}`,
|
||||
};
|
||||
}), [findLocalProviderForRow, lang, providerCopySourceForRow, providerRows]);
|
||||
}), [findLocalProviderForRow, lang, providerCopySourceForRow, providerRows, providerOrder, state?.codexDir]);
|
||||
|
||||
const saveProviderOrder = async (order: string[]): Promise<boolean> => {
|
||||
const directory = state?.codexDir;
|
||||
if (!directory || providerOrder.directory !== directory || loadingTokensRef.current.size || actionBusyTokensRef.current.size) return false;
|
||||
const previous = providerOrder;
|
||||
const request = ++providerOrderRequestRef.current;
|
||||
const actionToken = beginActionBusy("reorderProviders");
|
||||
setProviderOrder({ directory, order });
|
||||
try {
|
||||
const saved = await invoke<string[]>("save_provider_order", { configDir: directory, order });
|
||||
if (request === providerOrderRequestRef.current && normalizedConfigDirForComparison(directory) === activeConfigDirKeyRef.current) {
|
||||
setProviderOrder({ directory, order: saved });
|
||||
setToast(lang === "zh" ? "供应商顺序已保存" : "Provider order saved");
|
||||
}
|
||||
return true;
|
||||
} catch (nextError) {
|
||||
if (request === providerOrderRequestRef.current && normalizedConfigDirForComparison(directory) === activeConfigDirKeyRef.current) {
|
||||
setProviderOrder(previous);
|
||||
setError(String(nextError));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
endActionBusy(actionToken);
|
||||
}
|
||||
};
|
||||
|
||||
const visibleSessions = React.useMemo(
|
||||
() => (sessionStatus?.sessions || []).filter((item) => showInternalSessions || !item.isSubagent),
|
||||
@@ -1331,6 +1384,7 @@ function App() {
|
||||
}, [beginLoading, endLoading]);
|
||||
|
||||
const refresh = React.useCallback((includeDiagnostics: boolean) => {
|
||||
if (Array.from(actionBusyTokensRef.current.values()).includes("reorderProviders")) return;
|
||||
invalidatePromptDetail();
|
||||
const requestId = ++refreshRequestRef.current;
|
||||
const profilesRequestId = ++officialProfilesRequestRef.current;
|
||||
@@ -1378,7 +1432,16 @@ function App() {
|
||||
setStartupDiagnosticsLoading(false);
|
||||
}
|
||||
|
||||
void invoke<CodexState>("get_codex_state", { configDir: resolvedConfigDir })
|
||||
void invoke<{ changed: boolean }>("repair_provider_model_catalog", { configDir: resolvedConfigDir })
|
||||
.then((repair) => {
|
||||
if (repair.changed && requestId === refreshRequestRef.current) {
|
||||
setToast(lang === "zh" ? "已修复第三方模型的图片输入能力,请重启 Codex 加载新菜单" : "Third-party model image input repaired. Restart Codex to reload the model menu.");
|
||||
}
|
||||
})
|
||||
.catch((repairError) => {
|
||||
if (requestId === refreshRequestRef.current) setError(String(repairError));
|
||||
})
|
||||
.then(() => invoke<CodexState>("get_codex_state", { configDir: resolvedConfigDir }))
|
||||
.then((next) => {
|
||||
if (requestId !== refreshRequestRef.current) return;
|
||||
if (normalizedConfigDirForComparison(next.codexDir)
|
||||
@@ -1391,6 +1454,7 @@ function App() {
|
||||
setCreatingProvider(false);
|
||||
setEditingDetectedProvider(false);
|
||||
setProviderTomlDirty(false);
|
||||
setProviderCommonConfigDirty(false);
|
||||
setProviderTomlDraft("");
|
||||
setAvailableProviderModels([]);
|
||||
setProviderModelsLoading(false);
|
||||
@@ -1425,7 +1489,7 @@ function App() {
|
||||
setRefreshing(false);
|
||||
setError(String(nextError));
|
||||
});
|
||||
}, [clearActionBusy, configDir, configDirDraft, invalidatePromptDetail, state?.codexDir]);
|
||||
}, [clearActionBusy, configDir, configDirDraft, invalidatePromptDetail, lang, state?.codexDir]);
|
||||
|
||||
// Independent of get_codex_state: this must still work when a broken TOML
|
||||
// prevents the normal app state from loading. No shared loading flags change.
|
||||
@@ -1447,6 +1511,88 @@ function App() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const directory = configDir;
|
||||
const scope = normalizedConfigDirForComparison(directory);
|
||||
if (!scope) return;
|
||||
let disposed = false;
|
||||
let inFlight = false;
|
||||
let pending = false;
|
||||
let eventRevision = 0;
|
||||
let unlisten: (() => void) | undefined;
|
||||
const canRead = () => !disposed
|
||||
&& routingUiRef.current.ready && !routingUiRef.current.editing
|
||||
&& document.visibilityState !== "hidden"
|
||||
&& scope === activeConfigDirKeyRef.current
|
||||
&& loadingTokensRef.current.size === 0 && actionBusyTokensRef.current.size === 0;
|
||||
const drain = async () => {
|
||||
if (!pending || inFlight || !canRead()) return;
|
||||
pending = false;
|
||||
inFlight = true;
|
||||
const eventGeneration = eventRevision;
|
||||
const refreshGeneration = refreshRequestRef.current;
|
||||
const loadingGeneration = loadingGenerationRef.current;
|
||||
const actionGeneration = actionBusyGenerationRef.current;
|
||||
const profileGeneration = officialProfilesRequestRef.current;
|
||||
const stillCurrent = () => canRead()
|
||||
&& refreshGeneration === refreshRequestRef.current
|
||||
&& loadingGeneration === loadingGenerationRef.current
|
||||
&& actionGeneration === actionBusyGenerationRef.current
|
||||
&& eventGeneration === eventRevision;
|
||||
try {
|
||||
const [next, profiles] = await Promise.allSettled([
|
||||
invoke<CodexState>("get_codex_state", { configDir: directory }),
|
||||
invoke<OfficialProfileSummary[]>("list_official_profiles", { configDir: directory }),
|
||||
]);
|
||||
if (!stillCurrent()) {
|
||||
pending = !disposed;
|
||||
return;
|
||||
}
|
||||
if (next.status === "fulfilled" && normalizedConfigDirForComparison(next.value.codexDir) === scope) {
|
||||
setState((current) => current && stillCurrent()
|
||||
&& normalizedConfigDirForComparison(current.codexDir) === scope ? next.value : current);
|
||||
}
|
||||
if (profiles.status === "fulfilled" && profileGeneration === officialProfilesRequestRef.current) {
|
||||
officialProfilesRequestRef.current += 1;
|
||||
officialProfilesLiveRef.current = profiles.value;
|
||||
setOfficialProfiles(profiles.value);
|
||||
}
|
||||
} catch {
|
||||
// A transient bridge failure leaves the current screen and drafts intact.
|
||||
} finally {
|
||||
inFlight = false;
|
||||
// Coalesce events arriving during a read. A busy action/editor will
|
||||
// resume this pending update after it closes, preserving unsaved drafts.
|
||||
if (pending && canRead()) void drain();
|
||||
}
|
||||
};
|
||||
const wake = () => { void drain(); };
|
||||
const request = () => { pending = true; eventRevision += 1; wake(); };
|
||||
routingWakeRef.current = wake;
|
||||
routingRequestRefreshRef.current = request;
|
||||
const onFocus = () => request();
|
||||
const onVisibility = () => { if (document.visibilityState !== "hidden") request(); };
|
||||
// Backend paths may be canonical while this home is a symlink/alias. Events
|
||||
// are hints only: always re-read this configured home, never the payload path.
|
||||
void listen<{ codexDir: string }>("provider-routing-changed", () => request())
|
||||
.then((release) => { if (disposed) release(); else unlisten = release; }).catch(() => {});
|
||||
window.addEventListener("focus", onFocus);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
disposed = true;
|
||||
pending = false;
|
||||
unlisten?.();
|
||||
if (routingWakeRef.current === wake) routingWakeRef.current = () => {};
|
||||
if (routingRequestRefreshRef.current === request) routingRequestRefreshRef.current = () => {};
|
||||
window.removeEventListener("focus", onFocus);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [configDir]);
|
||||
|
||||
React.useEffect(() => {
|
||||
routingWakeRef.current();
|
||||
}, [loading, actionBusy, refreshing, providerMode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const directory = state?.codexDir;
|
||||
if (!directory || !(tab === "dashboard" || (tab === "provider" && providerMode === "list"))) return;
|
||||
@@ -1831,7 +1977,7 @@ function App() {
|
||||
const applyAfterSave = editingDetectedProvider
|
||||
|| Boolean(editingProviderId && editingProviderId === effectiveActiveProviderId);
|
||||
const applied = applyAfterSave
|
||||
? await invoke<ActionResult>("save_active_provider", { provider, configDir: configDir || null })
|
||||
? await invoke<ActionResult>("save_active_provider", { provider, configDir: configDir || null, applyCommonConfig: providerCommonConfigDirty })
|
||||
: null;
|
||||
if (!applyAfterSave) await invoke<SavedProvider>("save_provider", { provider });
|
||||
const providerList = await invoke<SavedProvider[]>("list_saved_providers");
|
||||
@@ -1844,6 +1990,7 @@ function App() {
|
||||
setEditingProviderId(null);
|
||||
setEditingDetectedProvider(false);
|
||||
setProviderTomlDirty(false);
|
||||
setProviderCommonConfigDirty(false);
|
||||
setToast(applied && pendingProvider.modelMappings?.length
|
||||
? (lang === "zh" ? "已保存,请重启 Codex 更新模型菜单" : "Saved. Restart Codex to update its model menu")
|
||||
: applied
|
||||
@@ -2368,6 +2515,7 @@ function App() {
|
||||
};
|
||||
|
||||
const openOfficialEdit = async (profileId = DEFAULT_OFFICIAL_PROFILE_ID) => {
|
||||
providerCreationRequestRef.current += 1;
|
||||
const requestId = ++officialDraftRequestRef.current;
|
||||
const actionToken = beginActionBusy("loadOfficialDraft");
|
||||
const profile = officialProfiles.find((item) => item.id === profileId);
|
||||
@@ -2498,30 +2646,44 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const newCustomProviderForm = (): SavedProvider => ({
|
||||
const newCustomProviderForm = (configText = providerCreationBase): SavedProvider => ({
|
||||
...blankProviderForm,
|
||||
model: state?.model?.trim() || blankProviderForm.model,
|
||||
wireApi: currentProvider?.wireApi?.trim() || blankProviderForm.wireApi,
|
||||
requiresOpenaiAuth: currentProvider?.requiresOpenaiAuth ?? blankProviderForm.requiresOpenaiAuth,
|
||||
tomlConfig: state?.configText?.trim() || "",
|
||||
tomlConfig: configText.trim(),
|
||||
});
|
||||
|
||||
const openAddProvider = () => {
|
||||
officialDraftRequestRef.current += 1;
|
||||
setCreatingProvider(true);
|
||||
setSelectedPresetId("custom");
|
||||
setSelectedPresetVariantId("");
|
||||
setEditingOfficialProfileId(null);
|
||||
setOfficialAuthDirty(false);
|
||||
setOfficialForm({ providerName: "", model: state?.model || "gpt-5.5", configText: "", authJson: "" });
|
||||
const next = newCustomProviderForm();
|
||||
resetAvailableProviderModels();
|
||||
setEditingProviderId(null);
|
||||
setEditingDetectedProvider(false);
|
||||
setProviderForm(next);
|
||||
setProviderTomlDraft(next.tomlConfig || buildProviderTomlPreview(next));
|
||||
setProviderTomlDirty(false);
|
||||
setProviderMode("form");
|
||||
const requestId = ++providerCreationRequestRef.current;
|
||||
const requestedDir = activeConfigDirKeyRef.current;
|
||||
const contextGeneration = refreshRequestRef.current;
|
||||
// Read a fresh, shared configuration once for this creation flow. The UI's
|
||||
// last state may be stale or a legacy provider-only template.
|
||||
return call(
|
||||
() => invoke<string>("get_provider_config_base", { configDir: configDir || null }),
|
||||
(configText) => {
|
||||
if (requestId !== providerCreationRequestRef.current || requestedDir !== activeConfigDirKeyRef.current
|
||||
|| contextGeneration !== refreshRequestRef.current) return;
|
||||
officialDraftRequestRef.current += 1;
|
||||
setProviderCreationBase(configText);
|
||||
setCreatingProvider(true);
|
||||
setSelectedPresetId("custom");
|
||||
setSelectedPresetVariantId("");
|
||||
setEditingOfficialProfileId(null);
|
||||
setOfficialAuthDirty(false);
|
||||
setOfficialForm({ providerName: "", model: state?.model || "gpt-5.5", configText: "", authJson: "" });
|
||||
const next = newCustomProviderForm(configText);
|
||||
resetAvailableProviderModels();
|
||||
setEditingProviderId(null);
|
||||
setEditingDetectedProvider(false);
|
||||
setProviderForm(next);
|
||||
setProviderTomlDraft(next.tomlConfig || buildProviderTomlPreview(next));
|
||||
setProviderTomlDirty(false);
|
||||
setProviderCommonConfigDirty(false);
|
||||
setProviderMode("form");
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const applyProviderPreset = (presetId: string, variantId: string) => {
|
||||
@@ -2551,12 +2713,14 @@ function App() {
|
||||
setProviderForm(next);
|
||||
setProviderTomlDraft(next.tomlConfig || buildProviderTomlPreview(next));
|
||||
setProviderTomlDirty(false);
|
||||
setProviderCommonConfigDirty(false);
|
||||
setProviderApiKeyVisible(false);
|
||||
setAvailableProviderModels(variant?.models.map((entry) => ({ id: entry.model })) || []);
|
||||
setProviderMode("form");
|
||||
};
|
||||
|
||||
const openEditProvider = (provider: SavedProvider) => {
|
||||
providerCreationRequestRef.current += 1;
|
||||
setCreatingProvider(false);
|
||||
resetAvailableProviderModels();
|
||||
setEditingProviderId(provider.id);
|
||||
@@ -2564,6 +2728,7 @@ function App() {
|
||||
setProviderForm(provider);
|
||||
setProviderTomlDraft(provider.tomlConfig?.trim() || buildProviderTomlPreview(provider));
|
||||
setProviderTomlDirty(false);
|
||||
setProviderCommonConfigDirty(false);
|
||||
setProviderMode("form");
|
||||
};
|
||||
|
||||
@@ -2590,6 +2755,7 @@ function App() {
|
||||
};
|
||||
|
||||
const openEditDetectedProvider = (provider: { id: string; providerName: string; baseUrl: string; model: string; apiKey?: string; wireApi: string; requiresOpenaiAuth: boolean }) => {
|
||||
providerCreationRequestRef.current += 1;
|
||||
setCreatingProvider(false);
|
||||
resetAvailableProviderModels();
|
||||
const id = uniqueId(
|
||||
@@ -2611,6 +2777,7 @@ function App() {
|
||||
setProviderForm(next);
|
||||
setProviderTomlDraft(next.tomlConfig || buildProviderTomlPreview(next));
|
||||
setProviderTomlDirty(false);
|
||||
setProviderCommonConfigDirty(false);
|
||||
setProviderMode("form");
|
||||
};
|
||||
|
||||
@@ -2677,7 +2844,7 @@ function App() {
|
||||
const syncCount = sessionMismatchCount(status);
|
||||
setToast(hasMismatches
|
||||
? (lang === "zh" ? `有 ${syncCount} 条会话需要同步` : `${syncCount} session(s) need syncing`)
|
||||
: (lang === "zh" ? "全部会话已同步" : "All sessions are synced"));
|
||||
: (lang === "zh" ? "普通会话已同步" : "User conversations are synced"));
|
||||
},
|
||||
);
|
||||
endActionBusy(actionToken);
|
||||
@@ -2928,7 +3095,7 @@ function App() {
|
||||
? `${state.agentsPath} (${lang === "zh" ? "追加模式" : "append"})`
|
||||
: state.instructionFile)
|
||||
: null}
|
||||
loading={loading || refreshing}
|
||||
loading={loading || refreshing || actionBusy === "reorderProviders"}
|
||||
hasUpdate={Boolean(releaseInfo.status === "ok" && releaseInfo.hasUpdate)}
|
||||
latestVersion={releaseInfo.latestVersion}
|
||||
onConfigDirChange={setConfigDirDraft}
|
||||
@@ -2954,6 +3121,8 @@ function App() {
|
||||
loading={loading}
|
||||
testingId={providerTestingId}
|
||||
actionBusy={actionBusy}
|
||||
orderBusy={providerOrder.directory !== state.codexDir}
|
||||
onReorderProviders={saveProviderOrder}
|
||||
editingProviderId={editingProviderId || (editingDetectedProvider ? providerForm.id : null)}
|
||||
providerForm={{
|
||||
apiKey: providerForm.apiKey || "",
|
||||
@@ -3031,6 +3200,7 @@ function App() {
|
||||
setCreatingProvider(false);
|
||||
setEditingDetectedProvider(false);
|
||||
setProviderTomlDirty(false);
|
||||
setProviderCommonConfigDirty(false);
|
||||
}}
|
||||
onOfficialModelChange={(value) => setOfficialForm((current) => ({ ...current, model: value }))}
|
||||
onOfficialNameChange={(value) => setOfficialForm((current) => ({ ...current, providerName: value }))}
|
||||
@@ -3059,10 +3229,11 @@ function App() {
|
||||
onWireApiChange={(value) => setProviderForm((current) => ({ ...current, wireApi: value }))}
|
||||
onRequiresAuthChange={(value) => setProviderForm((current) => ({ ...current, requiresOpenaiAuth: value }))}
|
||||
onToggleApiKeyVisibility={() => setProviderApiKeyVisible((value) => !value)}
|
||||
onProviderTomlDraftChange={(value) => {
|
||||
onProviderTomlDraftChange={(value, origin = "manual") => {
|
||||
providerDraftRequestRef.current += 1;
|
||||
setProviderTomlDraft(value);
|
||||
setProviderTomlDirty(true);
|
||||
if (origin === "manual") setProviderCommonConfigDirty(true);
|
||||
}}
|
||||
onResetProviderToml={() => {
|
||||
providerDraftRequestRef.current += 1;
|
||||
@@ -3072,6 +3243,7 @@ function App() {
|
||||
|| providerTomlPreview,
|
||||
);
|
||||
setProviderTomlDirty(false);
|
||||
setProviderCommonConfigDirty(false);
|
||||
setProviderDraftRefreshToken((token) => token + 1);
|
||||
}}
|
||||
onSaveProvider={saveProviderConfig}
|
||||
@@ -3274,8 +3446,7 @@ function App() {
|
||||
? "success"
|
||||
: "neutral",
|
||||
checking: releaseInfo.status === "checking"
|
||||
|| updater.state.phase === "downloading"
|
||||
|| updater.state.phase === "installing",
|
||||
|| isAppUpdateBusy(updater.state.phase),
|
||||
canOpenReleases: Boolean(releaseInfo.htmlUrl),
|
||||
}}
|
||||
onOpenProject={() => openExternalUrl(aboutInfo?.projectUrl || `https://github.com/${FALLBACK_GITHUB_REPO}`)}
|
||||
@@ -3289,6 +3460,7 @@ function App() {
|
||||
<SettingsPage
|
||||
lang={lang}
|
||||
configDir={configDir}
|
||||
onChange={async () => { routingRequestRefreshRef.current(); }}
|
||||
generalRequest={settingsGeneralRequest}
|
||||
configHealthStatus={<ConfigHealthStatus
|
||||
lang={lang}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Activity, AlertCircle, ArrowDown, ArrowUp, CheckCircle2, Clock3, Loader2, Plus, Power, RefreshCw, RotateCcw, Server, ShieldCheck, Shuffle, Trash2 } from "lucide-react";
|
||||
import { Button, IconButton } from "../components/ui";
|
||||
import { parseRoutingDraft, routingDraft, routingFields, sameRoutingDraft, type RoutingDraft, type RoutingSettings } from "../routingSettings";
|
||||
import "../styles/provider-failover.css";
|
||||
|
||||
type Language = "zh" | "en";
|
||||
type Provider = { id: string; providerName: string; model: string; models: string[]; baseUrl?: string | null; eligible: boolean; reason: string | null; official: boolean };
|
||||
type Health = { id: string; state: "closed" | "open" | "half_open"; cooldownSeconds: number; lastStatus: number | null; consecutiveFailures: number; consecutiveSuccesses: number; totalRequests: number; failedRequests: number };
|
||||
export type FailoverStatus = {
|
||||
settings: RoutingSettings; running: boolean; takeoverActive: boolean; autoFailoverActive: boolean; address: string | null;
|
||||
primary: Provider | null; providers: Provider[];
|
||||
runtime: { requestCount: number; failoverCount: number; inFlight: number; successCount: number; failureCount: number; uptimeSeconds: number; lastRequestAt: string | null; lastProviderId: string | null; lastError: string | null; providers: Health[] };
|
||||
message: string | null;
|
||||
};
|
||||
type PageState = { dir: string; status: FailoverStatus; draft: RoutingDraft; baseline: RoutingDraft };
|
||||
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
||||
|
||||
function SwitchRow({ id, icon, title, hint, checked, disabled, onChange }: {
|
||||
id: string; icon: React.ReactNode; title: string; hint: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return <div className="cx-failover-toggle-row"><span className="cx-failover-symbol">{icon}</span><div className="cx-failover-toggle-copy"><label id={`${id}-label`} htmlFor={id}>{title}</label><p id={`${id}-hint`}>{hint}</p></div><button id={id} className="cx-failover-switch" type="button" role="switch" aria-checked={checked} aria-labelledby={`${id}-label`} aria-describedby={`${id}-hint`} disabled={disabled} onClick={() => onChange(!checked)}><span /></button></div>;
|
||||
}
|
||||
|
||||
export function ProviderFailoverPage({ lang, configDir, active = true, onChange }: {
|
||||
lang: Language; configDir: string; active?: boolean; onChange?: () => void | Promise<void>;
|
||||
}) {
|
||||
const zh = lang === "zh";
|
||||
const tr = (cn: string, en: string) => zh ? cn : en;
|
||||
const id = useId();
|
||||
const [page, setPage] = useState<PageState | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [resetting, setResetting] = useState<string | null>(null);
|
||||
const [warning, setWarning] = useState("");
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const generation = useRef(0);
|
||||
const busy = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
const current = useRef({ active, configDir });
|
||||
const pageRef = useRef(page);
|
||||
current.current = { active, configDir };
|
||||
pageRef.current = page;
|
||||
|
||||
const acceptStatus = (status: FailoverStatus, preserveDraft: boolean) => {
|
||||
setPage((previous) => {
|
||||
const saved = routingDraft(status.settings);
|
||||
const keep = preserveDraft && previous?.dir === configDir && !sameRoutingDraft(previous.draft, previous.baseline)
|
||||
&& !sameRoutingDraft(previous.draft, saved);
|
||||
return { dir: configDir, status, draft: keep ? previous.draft : saved, baseline: keep ? previous.baseline : routingDraft(status.settings) };
|
||||
});
|
||||
};
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
useEffect(() => { setNotice(""); setSaveError(""); setWarning(""); }, [configDir]);
|
||||
useEffect(() => {
|
||||
const version = ++generation.current;
|
||||
if (!active) return;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const valid = () => mounted.current && generation.current === version && current.current.active && current.current.configDir === configDir;
|
||||
const load = async () => {
|
||||
if (!valid()) return;
|
||||
if (busy.current || document.hidden) { timer = setTimeout(() => void load(), 5000); return; }
|
||||
setLoading(pageRef.current?.dir !== configDir);
|
||||
try {
|
||||
const status = await invoke<FailoverStatus>("get_provider_failover", { configDir: configDir || null });
|
||||
if (!valid()) return;
|
||||
acceptStatus(status, true);
|
||||
setWarning("");
|
||||
} catch (error) {
|
||||
if (valid()) setWarning(errorText(error));
|
||||
} finally {
|
||||
if (valid()) { setLoading(false); timer = setTimeout(() => void load(), 5000); }
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => { ++generation.current; if (timer) clearTimeout(timer); };
|
||||
}, [active, configDir, refresh]);
|
||||
|
||||
const view = page?.dir === configDir ? page : null;
|
||||
const status = view?.status;
|
||||
const draft = view?.draft;
|
||||
const dirty = Boolean(view && !sameRoutingDraft(view.draft, view.baseline));
|
||||
const changedElsewhere = Boolean(view && dirty && !sameRoutingDraft(routingDraft(view.status.settings), view.baseline));
|
||||
const parsed = draft ? parseRoutingDraft(draft) : null;
|
||||
const selected = draft?.providerIds ?? [];
|
||||
const providers = status?.providers.filter((provider) => !provider.official) ?? [];
|
||||
const candidates = providers.filter((provider) => !selected.includes(provider.id));
|
||||
const invalidQueue = selected.some((item) => !providers.find((provider) => provider.id === item)?.eligible);
|
||||
const enablingAuto = Boolean(draft?.autoFailoverEnabled && !status?.settings.autoFailoverEnabled);
|
||||
const validation = changedElsewhere ? tr("设置已在别处更新,请先撤销修改,再重新保存。", "Settings changed elsewhere. Discard your changes before saving again.")
|
||||
: parsed && !parsed.settings ? tr("请检查标红的输入项。", "Check the highlighted fields.")
|
||||
: draft?.takeoverEnabled && !draft.routerEnabled ? tr("请先开启路由总开关。", "Enable the routing service first.")
|
||||
: draft?.takeoverEnabled && !status?.primary?.eligible ? status?.primary?.reason || tr("请先选择一个可用的供应商或官方账号。", "Select an available provider or official account first.")
|
||||
: enablingAuto && (!draft?.routerEnabled || !draft.takeoverEnabled) ? tr("请先开启本地路由和 Codex 路由。", "Enable the local router and Codex routing first.")
|
||||
: enablingAuto && !selected.length && (!status?.primary?.eligible || status.primary.official) ? tr("请添加至少一个 API 供应商作为 P1。", "Add an API provider as P1 first.")
|
||||
: invalidQueue ? tr("队列中有不可用的供应商,请移除或修复后保存。", "Remove or fix unavailable providers in the queue before saving.") : "";
|
||||
const controlsBusy = saving || resetting !== null;
|
||||
const edit = (patch: Partial<RoutingDraft>) => {
|
||||
if (busy.current) return;
|
||||
setPage((previous) => previous?.dir === configDir ? { ...previous, draft: { ...previous.draft, ...patch } } : previous);
|
||||
setSaveError(""); setNotice("");
|
||||
};
|
||||
const reset = () => {
|
||||
if (!view || busy.current) return;
|
||||
acceptStatus(view.status, false); setNotice(""); setSaveError("");
|
||||
};
|
||||
const reorder = (index: number, offset: number) => {
|
||||
if (index + offset < 0 || index + offset >= selected.length) return;
|
||||
const providerIds = [...selected];
|
||||
[providerIds[index], providerIds[index + offset]] = [providerIds[index + offset], providerIds[index]];
|
||||
edit({ providerIds });
|
||||
};
|
||||
const save = async () => {
|
||||
if (!view || !draft || !dirty || validation || busy.current || !active || !parsed?.settings) return;
|
||||
const version = ++generation.current;
|
||||
busy.current = true; setSaving(true); setSaveError(""); setNotice("");
|
||||
try {
|
||||
const result = await invoke<FailoverStatus>("save_provider_failover", { configDir: configDir || null, settings: parsed.settings });
|
||||
if (mounted.current && current.current.configDir === configDir && generation.current === version) {
|
||||
acceptStatus(result, false); setWarning("");
|
||||
setNotice(tr("路由设置已保存。", "Routing settings saved."));
|
||||
}
|
||||
if (current.current.configDir === configDir) {
|
||||
try { await onChange?.(); } catch { /* A later status read remains authoritative. */ }
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted.current && current.current.configDir === configDir && generation.current === version) setSaveError(errorText(error));
|
||||
} finally {
|
||||
busy.current = false;
|
||||
if (mounted.current) { setSaving(false); setRefresh((value) => value + 1); }
|
||||
}
|
||||
};
|
||||
const resetHealth = async (providerId: string) => {
|
||||
if (busy.current || !active) return;
|
||||
const version = ++generation.current;
|
||||
busy.current = true; setResetting(providerId); setSaveError(""); setNotice("");
|
||||
try {
|
||||
const result = await invoke<FailoverStatus>("reset_provider_failover_health", { configDir: configDir || null, providerId });
|
||||
if (mounted.current && current.current.configDir === configDir && generation.current === version) {
|
||||
acceptStatus(result, true);
|
||||
setNotice(tr("已清除该供应商的故障状态,可重新尝试。", "Provider health reset. It can be tried again."));
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted.current && current.current.configDir === configDir && generation.current === version) setSaveError(errorText(error));
|
||||
} finally {
|
||||
busy.current = false;
|
||||
if (mounted.current) { setResetting(null); setRefresh((value) => value + 1); }
|
||||
}
|
||||
};
|
||||
const healthFor = (providerId: string) => status?.runtime.providers.find((provider) => provider.id === providerId);
|
||||
const healthBadge = (providerId: string) => {
|
||||
const health = healthFor(providerId);
|
||||
if (!status?.running || !health) return <span className="cx-failover-health cx-failover-health--idle">{tr("未使用", "Not used")}</span>;
|
||||
const label = health.state === "open" ? `${tr("暂时跳过", "Unavailable")} · ${Math.ceil(health.cooldownSeconds)}s`
|
||||
: health.state === "half_open" ? tr("试探恢复", "Recovering") : tr("正常", "Healthy");
|
||||
return <span className={`cx-failover-health cx-failover-health--${health.state}`} title={tr(`连续失败 ${health.consecutiveFailures} 次 · ${health.failedRequests}/${health.totalRequests} 次失败`, `${health.consecutiveFailures} consecutive failures · ${health.failedRequests}/${health.totalRequests} failed`)}><i />{label}</span>;
|
||||
};
|
||||
const primary = status?.primary;
|
||||
const recent = status?.runtime.lastProviderId === primary?.id ? primary : providers.find((provider) => provider.id === status?.runtime.lastProviderId);
|
||||
const uptime = status ? `${Math.floor(status.runtime.uptimeSeconds / 3600)}h ${Math.floor(status.runtime.uptimeSeconds % 3600 / 60)}m` : "—";
|
||||
const fieldError = (key: keyof NonNullable<typeof parsed>["errors"]) => parsed?.errors[key];
|
||||
|
||||
return <section className="cx-failover" aria-busy={loading || controlsBusy}>
|
||||
<header className="cx-failover-heading"><div><h3>{tr("路由与故障转移", "Routing & failover")}</h3><p>{tr("管理 Codex 的连接方式,让请求在供应商故障时继续完成。", "Manage Codex routing and keep requests moving when a provider is unavailable.")}</p></div><IconButton size="sm" label={tr("刷新运行状态", "Refresh routing status")} icon={<RefreshCw size={17} className={loading ? "cx-page-spin" : ""} />} disabled={controlsBusy || loading} onClick={() => setRefresh((value) => value + 1)} /></header>
|
||||
{warning && <div className="cx-failover-message cx-failover-message--warning" role="status"><AlertCircle size={18} /><div>{status && <strong>{tr("状态更新失败,仍显示上次结果。", "Unable to update status. Showing the last result.")}</strong>}<span>{warning}</span></div>{!status && <Button size="sm" variant="secondary" onClick={() => setRefresh((value) => value + 1)}>{tr("重试", "Retry")}</Button>}</div>}
|
||||
{!status || !draft ? (loading ? <div className="cx-failover-loading"><Loader2 size={20} className="cx-page-spin" />{tr("正在读取路由设置…", "Loading routing settings…")}</div> : null) : <>
|
||||
<section className="cx-failover-card">
|
||||
<div className="cx-failover-card-heading"><h4><Server size={18} />{tr("本地路由", "Local routing")}</h4><span className={`cx-failover-state${status.running ? " cx-failover-state--running" : ""}`}><i />{status.running ? tr("运行中", "Running") : tr("已停止", "Stopped")}</span></div>
|
||||
<SwitchRow id={`${id}-router`} icon={<Power size={22} />} title={tr("路由总开关", "Routing service")} hint={tr("启动本机路由服务;更改将在保存后生效。", "Run the local routing service. Changes take effect when saved.")} checked={draft.routerEnabled} disabled={controlsBusy} onChange={(checked) => edit({ routerEnabled: checked, ...(!checked ? { takeoverEnabled: false } : {}) })} />
|
||||
<div className="cx-failover-address-grid">
|
||||
<label htmlFor={`${id}-address`}>{tr("监听地址", "Listen address")}<input id={`${id}-address`} value={draft.listenAddress} disabled={controlsBusy || status.running} aria-invalid={Boolean(fieldError("listenAddress"))} onChange={(event) => edit({ listenAddress: event.target.value })} spellCheck={false} /><small className={fieldError("listenAddress") ? "cx-failover-field-error" : ""}>{fieldError("listenAddress") ? tr("请输入有效 IPv4、IPv6 或 localhost。", "Enter a valid IPv4, IPv6 or localhost.") : "IPv4 / IPv6 / localhost"}</small></label>
|
||||
<label htmlFor={`${id}-port`}>{tr("监听端口", "Listen port")}<input id={`${id}-port`} type="number" min={1024} max={65535} value={draft.listenPort} disabled={controlsBusy || status.running} aria-invalid={Boolean(fieldError("listenPort"))} onChange={(event) => edit({ listenPort: event.target.value })} /><small className={fieldError("listenPort") ? "cx-failover-field-error" : ""}>{fieldError("listenPort") ? tr("有效范围:1024–65535", "Valid range: 1024–65535") : status.running ? tr("停止并保存后可修改地址和端口。", "Stop and save before changing the address or port.") : "1024–65535"}</small></label>
|
||||
</div>
|
||||
<SwitchRow id={`${id}-takeover`} icon={<ShieldCheck size={22} />} title={tr("为 Codex 启用路由", "Route Codex requests")} hint={tr("让 Codex 请求经过本地路由;关闭后恢复直接连接。", "Send Codex requests through this router. Turn off to restore direct access.")} checked={draft.takeoverEnabled} disabled={controlsBusy || !draft.takeoverEnabled && (!draft.routerEnabled || !primary?.eligible)} onChange={(checked) => edit({ takeoverEnabled: checked })} />
|
||||
{(!primary?.eligible || !draft.routerEnabled) && <p className="cx-failover-inline-hint">{!draft.routerEnabled ? tr("先开启路由总开关,再选择是否接管 Codex。", "Enable the routing service before routing Codex requests.") : primary?.reason || tr("请先在供应商页面选择一个供应商或已登录的官方账号。", "Select a provider or a signed-in official account first.")}</p>}
|
||||
<div className="cx-failover-primary"><div className="cx-failover-provider-copy"><span className="cx-failover-label">{tr("当前供应商", "Current provider")}</span><strong>{primary?.providerName || tr("尚未选择", "None selected")}</strong><small>{primary?.baseUrl || primary?.model || "—"}</small></div>{primary?.official && <span className="cx-failover-state">{tr("官方登录", "Official account")}</span>}<span className={`cx-failover-state${status.takeoverActive ? " cx-failover-state--running" : ""}`}>{status.takeoverActive ? tr("已接管", "Routed") : tr("直接连接", "Direct access")}</span></div>
|
||||
{status.running && status.address && <div className="cx-failover-service-address"><span>{tr("当前路由地址", "Active route")}</span><code>{status.address}</code></div>}
|
||||
</section>
|
||||
|
||||
<section className="cx-failover-card">
|
||||
<div className="cx-failover-card-heading"><h4><Shuffle size={18} />{tr("自动故障转移", "Automatic failover")}</h4><span className={`cx-failover-state${status.autoFailoverActive ? " cx-failover-state--running" : ""}`}>{status.autoFailoverActive ? tr("已启用", "Active") : tr("未运行", "Inactive")}</span></div>
|
||||
<SwitchRow id={`${id}-auto`} icon={<Shuffle size={22} />} title={tr("自动切换供应商", "Switch providers automatically")} hint={tr("启用并保存后会切到 P1;每次请求按 P1 → P2 → P3 的顺序尝试。", "Enabling and saving switches to P1. Each request tries P1 → P2 → P3 in order.")} checked={draft.autoFailoverEnabled} disabled={controlsBusy || !draft.autoFailoverEnabled && (!draft.routerEnabled || !draft.takeoverEnabled)} onChange={(checked) => edit({ autoFailoverEnabled: checked, ...(checked && !selected.length && primary?.eligible && !primary.official ? { providerIds: [primary.id] } : {}) })} />
|
||||
<div className="cx-failover-queue-heading"><div><h4>{tr("优先级队列", "Priority queue")}</h4><p>{tr("可以提前准备队列。请求保留 Codex 中选择的模型,请确保供应商支持它。", "Prepare your queue at any time. Requests keep the model selected in Codex; providers must support it.")}</p></div><span>{selected.length}/64</span></div>
|
||||
{!selected.length ? <div className="cx-failover-empty"><strong>{tr("还没有队列供应商", "Your queue is empty")}</strong><span>{tr("从下方添加供应商,第一位就是 P1;一个供应商也可以启用。", "Add providers below. The first is P1; a one-provider queue is also supported.")}</span></div> : <ol className="cx-failover-queue">{selected.map((providerId, index) => {
|
||||
const provider = providers.find((item) => item.id === providerId);
|
||||
const health = healthFor(providerId);
|
||||
return <li key={providerId} className={`${index === 0 ? "cx-failover-provider--first" : ""}${!provider?.eligible ? " cx-failover-provider--invalid" : ""}`}><span className="cx-failover-order">P{index + 1}</span><div className="cx-failover-provider-copy"><strong>{provider?.providerName || tr("供应商已删除", "Provider deleted")}</strong><small title={provider?.baseUrl || undefined}>{!provider?.eligible ? provider?.reason || tr("请移除此项或检查供应商配置。", "Remove this item or fix its configuration.") : provider.baseUrl || provider.model}</small></div>{healthBadge(providerId)}<div className="cx-failover-row-actions">{health && health.state !== "closed" && <IconButton size="sm" label={`${tr("重置健康状态", "Reset health")} ${provider?.providerName || ""}`} icon={<RotateCcw size={15} className={resetting === providerId ? "cx-page-spin" : ""} />} disabled={controlsBusy} onClick={() => void resetHealth(providerId)} />}<IconButton size="sm" label={`${tr("上移", "Move up")} ${provider?.providerName || ""}`} icon={<ArrowUp size={15} />} disabled={controlsBusy || index === 0} onClick={() => reorder(index, -1)} /><IconButton size="sm" label={`${tr("下移", "Move down")} ${provider?.providerName || ""}`} icon={<ArrowDown size={15} />} disabled={controlsBusy || index === selected.length - 1} onClick={() => reorder(index, 1)} /><IconButton size="sm" label={`${tr("移除", "Remove")} ${provider?.providerName || ""}`} variant="ghost" icon={<Trash2 size={15} />} disabled={controlsBusy} onClick={() => edit({ providerIds: selected.filter((item) => item !== providerId) })} /></div></li>;
|
||||
})}</ol>}
|
||||
<div className="cx-failover-candidates"><h4>{tr("添加供应商", "Add providers")}</h4>{candidates.length ? <div className="cx-failover-candidate-list">{candidates.map((provider) => <button type="button" className="cx-failover-candidate" key={provider.id} disabled={controlsBusy || !provider.eligible || selected.length >= 64} title={provider.reason || provider.baseUrl || undefined} onClick={() => edit({ providerIds: [...selected, provider.id] })}><div><strong>{provider.providerName}</strong><small>{provider.eligible ? provider.baseUrl || provider.model : provider.reason}</small></div><Plus size={17} /><span className="cx-failover-sr-only">{tr("添加", "Add")}</span></button>)}</div> : <p>{tr("所有可选供应商已在队列中;也可以到供应商页面添加更多。", "All available providers are already queued. Add more on the Providers page.")}</p>}</div>
|
||||
{primary?.official && <p className="cx-failover-inline-hint">{tr("官方账号仅使用当前登录,不参与自动切换。队列只包含 API 供应商。", "Official accounts use the current login only. Only API providers participate in failover.")}</p>}
|
||||
</section>
|
||||
|
||||
<section className="cx-failover-card cx-failover-tuning"><div className="cx-failover-card-heading"><h4><Clock3 size={18} />{tr("重试、超时与恢复", "Retries, timeouts & recovery")}</h4><span className="cx-failover-section-note">{tr("自动故障转移时生效", "Applied during automatic failover")}</span></div>{(["retry", "timeout", "recovery"] as const).map((group) => <div className={`cx-failover-field-group cx-failover-field-group--${group}`} key={group}><h5>{group === "retry" ? tr("重试策略", "Retry policy") : group === "timeout" ? tr("超时时间", "Timeouts") : tr("故障恢复", "Recovery policy")}</h5><div className="cx-failover-fields">{routingFields.filter((field) => field.group === group).map((field) => <label key={field.key} htmlFor={`${id}-${field.key}`}>{zh ? field.zh : field.en}<div className="cx-failover-field-input"><input id={`${id}-${field.key}`} type="number" min={field.min} max={field.max} step={field.key === "circuitErrorRateThreshold" ? "any" : 1} value={draft[field.key]} disabled={controlsBusy} aria-invalid={Boolean(fieldError(field.key))} aria-describedby={`${id}-${field.key}-hint`} onChange={(event) => edit({ [field.key]: event.target.value })} /><span>{field.min}–{field.max}</span></div><small id={`${id}-${field.key}-hint`} className={fieldError(field.key) ? "cx-failover-field-error" : ""}>{fieldError(field.key) ? tr(`请输入 ${field.min}–${field.max} 范围内的${field.key === "circuitErrorRateThreshold" ? "数值" : "整数"}。`, `Enter ${field.min}–${field.max}${field.key === "circuitErrorRateThreshold" ? "." : " (whole numbers)."}`) : zh ? field.hintZh : field.hintEn}</small></label>)}</div></div>)}</section>
|
||||
|
||||
<section className="cx-failover-card cx-failover-runtime"><div className="cx-failover-card-heading"><h4><Activity size={18} />{tr("运行状态", "Runtime status")}</h4><span className="cx-failover-section-note">{tr("运行时间", "Uptime")} {uptime}</span></div><dl><div><dt>{tr("处理请求", "Requests")}</dt><dd>{status.runtime.requestCount.toLocaleString()}</dd></div><div><dt>{tr("成功 / 失败", "Succeeded / failed")}</dt><dd>{status.runtime.successCount.toLocaleString()} <span>/ {status.runtime.failureCount.toLocaleString()}</span></dd></div><div><dt>{tr("自动切换", "Failovers")}</dt><dd>{status.runtime.failoverCount.toLocaleString()}</dd></div><div><dt>{tr("正在处理", "In progress")}</dt><dd>{status.runtime.inFlight.toLocaleString()}</dd></div></dl><div className="cx-failover-runtime-current"><span>{tr("最近使用", "Last used")}</span><strong>{recent?.providerName || tr("等待请求", "Waiting for requests")}</strong>{status.runtime.lastRequestAt && <time>{new Date(status.runtime.lastRequestAt).toLocaleTimeString(lang === "zh" ? "zh-CN" : "en-US", { hour: "2-digit", minute: "2-digit" })}</time>}</div>{status.message && <p className="cx-failover-runtime-note">{status.message}</p>}{status.runtime.lastError && <p className="cx-failover-runtime-note"><AlertCircle size={15} />{status.runtime.lastError}</p>}</section>
|
||||
<p className="cx-failover-bottom-note">{tr("回复已经开始后不会重复发送。关闭 Codex-X 窗口仍会在后台运行;完全退出时恢复直接连接。", "A reply that has already started is never replayed. Closing this window keeps routing active; quitting Codex-X restores direct access.")}</p>
|
||||
{saveError && <div className="cx-failover-message cx-failover-message--warning" role="alert"><AlertCircle size={18} /><span>{saveError}</span></div>}
|
||||
{notice && <div className="cx-failover-message cx-failover-message--success" role="status"><CheckCircle2 size={18} /><span>{notice}</span></div>}
|
||||
<footer className="cx-failover-save"><div aria-live="polite" className={validation ? "cx-failover-field-error" : ""}>{validation || (dirty ? tr("有未保存的修改", "Unsaved changes") : tr("更改将在保存后生效", "Changes take effect when saved"))}</div><div><Button size="sm" variant="secondary" disabled={!dirty || controlsBusy} onClick={reset} icon={<RotateCcw size={15} />}>{tr("撤销修改", "Discard changes")}</Button><Button size="sm" disabled={!dirty || controlsBusy || Boolean(validation)} icon={saving ? <Loader2 size={15} className="cx-page-spin" /> : undefined} onClick={() => void save()}>{saving ? tr("保存中…", "Saving…") : tr("保存设置", "Save settings")}</Button></div></footer>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
EyeOff,
|
||||
FilePlus2,
|
||||
Gauge,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
PencilLine,
|
||||
Plus,
|
||||
@@ -28,6 +29,7 @@ import { ProviderPresetPicker } from "../components/ProviderPresetPicker";
|
||||
import { PROVIDER_PRESETS, getProviderPreset, getProviderPresetVariant } from "../providerPresets";
|
||||
import { Button, Checkbox, ModalShell } from "../components/ui";
|
||||
import type { ProviderMode, ProviderModelMapping } from "../types";
|
||||
import { moveProviderRow, providerRowKey } from "../providerRowOrder";
|
||||
import "../styles/providers-page.css";
|
||||
|
||||
export type ProviderRowSource = "official" | "local" | "detected";
|
||||
@@ -38,6 +40,7 @@ export type ProviderRow = {
|
||||
providerName: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
modelDisplayName?: string;
|
||||
apiKey?: string;
|
||||
wireApi: string;
|
||||
requiresOpenaiAuth: boolean;
|
||||
@@ -149,6 +152,8 @@ export type ProvidersPageProps = {
|
||||
loading: boolean;
|
||||
testingId: string;
|
||||
actionBusy?: string;
|
||||
orderBusy?: boolean;
|
||||
onReorderProviders: (order: string[]) => Promise<boolean>;
|
||||
editingProviderId: string | null;
|
||||
creatingProvider: boolean;
|
||||
selectedPresetId: string;
|
||||
@@ -194,7 +199,7 @@ export type ProvidersPageProps = {
|
||||
onWireApiChange: (value: string) => void;
|
||||
onRequiresAuthChange: (value: boolean) => void;
|
||||
onToggleApiKeyVisibility: () => void;
|
||||
onProviderTomlDraftChange: (value: string) => void;
|
||||
onProviderTomlDraftChange: (value: string, origin?: "manual" | "context") => void;
|
||||
onResetProviderToml: () => void;
|
||||
onSaveProvider: () => void;
|
||||
};
|
||||
@@ -394,6 +399,7 @@ function ListPage({
|
||||
loading,
|
||||
testingId,
|
||||
actionBusy,
|
||||
orderBusy,
|
||||
onImportCcSwitch,
|
||||
onAddProvider,
|
||||
onEnableProvider,
|
||||
@@ -401,11 +407,76 @@ function ListPage({
|
||||
onEditProvider,
|
||||
onDuplicateProvider,
|
||||
onDeleteProvider,
|
||||
}: Pick<ProvidersPageProps, "lang" | "configDir" | "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDuplicateProvider" | "onDeleteProvider">) {
|
||||
onReorderProviders,
|
||||
}: Pick<ProvidersPageProps, "lang" | "configDir" | "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "orderBusy" | "onReorderProviders" | "onImportCcSwitch" | "onAddProvider" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDuplicateProvider" | "onDeleteProvider">) {
|
||||
const [providerToDelete, setProviderToDelete] = useState<ProviderRow | null>(null);
|
||||
const [quotaSelection, setQuotaSelection] = useState<{ id: string; configDir: string; email: string | null } | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const providerActionsBusy = loading || Boolean(actionBusy);
|
||||
const sortingDisabled = providerActionsBusy || Boolean(orderBusy) || providerRows.length < 2;
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const pointerDragRef = useRef<{ key: string; pointerId: number; startY: number; moved: boolean } | null>(null);
|
||||
const pointerPositionRef = useRef({ x: 0, y: 0 });
|
||||
const scrollFrameRef = useRef<number | null>(null);
|
||||
const dropTargetRef = useRef<{ key: string; position: "before" | "after" } | null>(null);
|
||||
const [draggedKey, setDraggedKey] = useState("");
|
||||
const [dropTarget, setDropTarget] = useState<{ key: string; position: "before" | "after" } | null>(null);
|
||||
const finishDrag = () => {
|
||||
if (scrollFrameRef.current !== null) cancelAnimationFrame(scrollFrameRef.current);
|
||||
scrollFrameRef.current = null;
|
||||
pointerDragRef.current = null;
|
||||
dropTargetRef.current = null;
|
||||
setDraggedKey("");
|
||||
setDropTarget(null);
|
||||
};
|
||||
useEffect(() => { finishDrag(); }, [configDir]);
|
||||
useEffect(() => () => { if (scrollFrameRef.current !== null) cancelAnimationFrame(scrollFrameRef.current); }, []);
|
||||
const reorder = async (source: string, target: string, position: "before" | "after") => {
|
||||
const current = providerRows.map(providerRowKey);
|
||||
const next = moveProviderRow(current, source, target, position);
|
||||
if (next.some((key, index) => key !== current[index])) {
|
||||
await onReorderProviders(next);
|
||||
if (document.activeElement === document.body || document.activeElement?.classList.contains("cx-providers-drag-handle")) {
|
||||
const card = Array.from(listRef.current?.querySelectorAll<HTMLElement>("[data-provider-key]") || []).find((item) => item.dataset.providerKey === source);
|
||||
card?.querySelector<HTMLButtonElement>(".cx-providers-drag-handle")?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
const updateDropTarget = (clientX: number, clientY: number) => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const bounds = list.getBoundingClientRect();
|
||||
if (clientX < bounds.left || clientX > bounds.right) {
|
||||
dropTargetRef.current = null;
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
const cards = Array.from(list.querySelectorAll<HTMLElement>("[data-provider-key]"));
|
||||
const card = cards.find((item) => clientY < item.getBoundingClientRect().bottom) || cards[cards.length - 1];
|
||||
if (!card) return;
|
||||
const rect = card.getBoundingClientRect();
|
||||
const target = { key: card.dataset.providerKey!, position: clientY < rect.top + rect.height / 2 ? "before" as const : "after" as const };
|
||||
dropTargetRef.current = target;
|
||||
setDropTarget(target);
|
||||
};
|
||||
const startAutoScroll = () => {
|
||||
if (scrollFrameRef.current !== null) return;
|
||||
const tick = () => {
|
||||
const list = listRef.current;
|
||||
if (!pointerDragRef.current?.moved || !list) { scrollFrameRef.current = null; return; }
|
||||
const { x, y } = pointerPositionRef.current;
|
||||
const bounds = list.getBoundingClientRect();
|
||||
if (x >= bounds.left && x <= bounds.right) {
|
||||
const speed = y < bounds.top + 48 ? -Math.min(16, (bounds.top + 48 - y) / 3)
|
||||
: y > bounds.bottom - 48 ? Math.min(16, (y - bounds.bottom + 48) / 3) : 0;
|
||||
const previous = list.scrollTop;
|
||||
list.scrollTop += speed;
|
||||
if (list.scrollTop !== previous) updateDropTarget(x, y);
|
||||
}
|
||||
scrollFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
scrollFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
const quotaProfile = quotaSelection?.configDir === configDir
|
||||
? providerRows.find((row) => row.source === "official" && row.id === quotaSelection.id && row.canQueryQuota && (row.email ?? null) === quotaSelection.email)
|
||||
: undefined;
|
||||
@@ -453,15 +524,58 @@ function ListPage({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="cx-providers-list" role="list">
|
||||
<p className="cx-providers-sort-hint">{lang === "zh" ? "拖动卡片左侧手柄调整顺序,自动保存。" : "Drag the handle on the left to reorder. Changes save automatically."}</p>
|
||||
<div className="cx-providers-list" role="list" ref={listRef}>
|
||||
{providerRows.length === 0 ? (
|
||||
<div className="cx-providers-empty" role="status">{copy.noProviders}</div>
|
||||
) : providerRows.map((row) => {
|
||||
const testingKey = row.testingKey || `${row.source}-${row.id}`;
|
||||
const isTesting = testingId === testingKey;
|
||||
const isProtectedOfficial = row.source === "official" && row.isDefaultOfficial === true;
|
||||
const key = providerRowKey(row);
|
||||
const modelName = row.modelDisplayName || row.model || (lang === "zh" ? "跟随 Codex" : "Follow Codex");
|
||||
return (
|
||||
<article className={`cx-providers-row${row.isCurrent ? " cx-providers-row--current" : ""}${row.source === "official" ? " cx-providers-row--official" : ""}`} key={`${row.source}-${row.id}-${row.baseUrl}`} role="listitem">
|
||||
<article className={`cx-providers-row${row.isCurrent ? " cx-providers-row--current" : ""}${row.source === "official" ? " cx-providers-row--official" : ""}${draggedKey === key ? " cx-providers-row--dragging" : ""}${dropTarget?.key === key && draggedKey !== key ? ` cx-providers-row--drop-${dropTarget.position}` : ""}`} key={key} role="listitem" data-provider-key={key}>
|
||||
<button type="button" className="cx-providers-drag-handle" disabled={sortingDisabled}
|
||||
aria-label={lang === "zh" ? `排序 ${row.providerName}:拖动或按上下箭头移动` : `Reorder ${row.providerName}: drag or use Up and Down arrows`}
|
||||
title={lang === "zh" ? "拖动排序,也可按上下箭头移动" : "Drag to reorder, or use Up and Down arrows"}
|
||||
onPointerDown={(event) => {
|
||||
if (sortingDisabled || event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
pointerDragRef.current = { key, pointerId: event.pointerId, startY: event.clientY, moved: false };
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const drag = pointerDragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId || sortingDisabled) return;
|
||||
if (!drag.moved && Math.abs(event.clientY - drag.startY) < 5) return;
|
||||
drag.moved = true;
|
||||
pointerPositionRef.current = { x: event.clientX, y: event.clientY };
|
||||
setDraggedKey(drag.key);
|
||||
updateDropTarget(event.clientX, event.clientY);
|
||||
startAutoScroll();
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const drag = pointerDragRef.current;
|
||||
const target = dropTargetRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
finishDrag();
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
const bounds = listRef.current?.getBoundingClientRect();
|
||||
const inside = bounds && event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top && event.clientY <= bounds.bottom;
|
||||
if (drag.moved && target && inside && !sortingDisabled) void reorder(drag.key, target.key, target.position);
|
||||
}}
|
||||
onPointerCancel={finishDrag}
|
||||
onLostPointerCapture={finishDrag}
|
||||
onKeyDown={(event) => {
|
||||
if (sortingDisabled || !["ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const index = providerRows.findIndex((entry) => providerRowKey(entry) === key);
|
||||
const target = providerRows[index + (event.key === "ArrowUp" ? -1 : 1)];
|
||||
if (target) void reorder(key, providerRowKey(target), event.key === "ArrowUp" ? "before" : "after");
|
||||
}}
|
||||
><GripVertical size={16} aria-hidden="true" /></button>
|
||||
<ProviderAvatar row={row} />
|
||||
<div className="cx-providers-row-content">
|
||||
<div className="cx-providers-row-main">
|
||||
@@ -474,6 +588,7 @@ function ListPage({
|
||||
)}
|
||||
</div>
|
||||
<code title={row.baseUrl || copy.noBaseUrlLabel}>{row.baseUrl || copy.noBaseUrlLabel}</code>
|
||||
<div className="cx-providers-row-model" title={row.model || modelName}><span>{lang === "zh" ? "模型" : "Model"}</span><b>{modelName}</b>{row.modelDisplayName && row.modelDisplayName !== row.model && <code>{row.model}</code>}</div>
|
||||
{row.source !== "official" && row.meta && <div className="cx-providers-row-meta">{row.meta}</div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -868,7 +983,7 @@ function ProviderForm({
|
||||
<div className="cx-providers-section-heading cx-providers-section-heading--with-action">
|
||||
<div><h3>{copy.tomlTitle}</h3><p>{copy.tomlDescription}</p></div>
|
||||
<div className="cx-providers-context-actions">
|
||||
<ContextWindowControl lang={lang} configText={providerTomlDraft} onConfigChange={onProviderTomlDraftChange} disabled={formBusy} onBusyChange={setContextWindowBusy} />
|
||||
<ContextWindowControl lang={lang} configText={providerTomlDraft} onConfigChange={(value) => onProviderTomlDraftChange(value, "context")} disabled={formBusy} onBusyChange={setContextWindowBusy} />
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={onResetProviderToml} disabled={formBusy}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,70 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Folder,
|
||||
FolderTree,
|
||||
History,
|
||||
Info,
|
||||
Loader2,
|
||||
Pin,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Checkbox, ModalShell, cx } from "../components/ui";
|
||||
import {
|
||||
mergeSessionDirectoryGroups,
|
||||
pinnedSessionDirectoryKeys,
|
||||
readSessionPinPreferences,
|
||||
sessionPinStorageKey,
|
||||
sessionsForDirectoryGroup,
|
||||
toggleFolderPins,
|
||||
type SessionDirectoryIdentities,
|
||||
type SessionPinPreferences,
|
||||
} from "../sessionDirectoryIdentity";
|
||||
import "../styles/session-management.css";
|
||||
|
||||
const SESSION_ROW_HEIGHT = 58;
|
||||
const SESSION_COLUMN_HEADER_HEIGHT = 34;
|
||||
const SESSION_VIRTUAL_OVERSCAN_ROWS = 8;
|
||||
const DEFAULT_SESSION_VIEWPORT_HEIGHT = 640;
|
||||
const SESSION_ALL_FOLDERS_KEY = "__codexx_all_folders__";
|
||||
// Defer the browser storage getter so unavailable storage is caught by the reader.
|
||||
const sessionPinStorage = { getItem: (key: string) => localStorage.getItem(key) };
|
||||
|
||||
type ScopedSessionPinPreferences = SessionPinPreferences & {
|
||||
storageKey: string;
|
||||
};
|
||||
|
||||
function folderDisplayName(value: string, missing: string) {
|
||||
if (!value) return missing;
|
||||
const parts = value.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] || missing;
|
||||
}
|
||||
|
||||
type SessionVirtualRow = {
|
||||
kind: "session";
|
||||
key: string;
|
||||
top: number;
|
||||
height: number;
|
||||
item: SessionPreview;
|
||||
};
|
||||
|
||||
function findVirtualRowIndex(rows: SessionVirtualRow[], offset: number) {
|
||||
let low = 0;
|
||||
let high = rows.length;
|
||||
while (low < high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
if (rows[middle].top + rows[middle].height <= offset) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
export type Lang = "zh" | "en";
|
||||
|
||||
export type SessionPreview = {
|
||||
@@ -32,6 +83,8 @@ export type SessionPreview = {
|
||||
|
||||
export type SessionSyncStatus = {
|
||||
codexDir: string;
|
||||
directoryIdentities?: SessionDirectoryIdentities;
|
||||
pinScopeKey?: string | null;
|
||||
targetProvider: string;
|
||||
rolloutFiles: number;
|
||||
sessionMetaCount: number;
|
||||
@@ -157,11 +210,12 @@ export function SessionManagementPage({
|
||||
onDeleteSafetyConfirmedChange,
|
||||
}: SessionManagementPageProps) {
|
||||
const isChinese = lang === "zh";
|
||||
const codexDir = sessionStatus?.codexDir || "default";
|
||||
const copy = isChinese
|
||||
? {
|
||||
syncEyebrow: "会话同步",
|
||||
title: "会话管理",
|
||||
description: "检查本地会话是否位于官方与中转共用的会话列表,需要时一键同步。不会修改聊天内容。",
|
||||
description: "同步普通会话到共享列表,内部任务不参与同步。不会修改聊天内容。",
|
||||
syncTo: "同步到",
|
||||
check: "检查会话",
|
||||
checking: "检查中...",
|
||||
@@ -173,11 +227,11 @@ export function SessionManagementPage({
|
||||
allSynced: "全部会话已同步",
|
||||
sessionCount: (count: number) => `${count} 条会话`,
|
||||
local: "本地会话",
|
||||
list: "会话列表",
|
||||
list: "文件夹与会话",
|
||||
shown: (shown: number, total: number) => `展示 ${shown} / ${total} 条`,
|
||||
loaded: (count: number) => `当前加载 ${count} 条`,
|
||||
search: "搜索标题 / 项目 / 供应商 / ID",
|
||||
groupByProject: "按项目路径分组",
|
||||
groupByProject: "文件夹视图",
|
||||
showInternal: (count: number) => `显示内部会话 (${count})`,
|
||||
deleteSelected: "删除选中",
|
||||
exportSelected: "导出选中",
|
||||
@@ -189,14 +243,22 @@ export function SessionManagementPage({
|
||||
selectProject: (path: string, count: number) => `选择项目 ${path} 的 ${count} 条会话`,
|
||||
projectCount: (count: number, truncated: boolean) => `${truncated ? "已加载 " : ""}${count} 条`,
|
||||
projectShown: (shown: number, total: number) => `显示 ${shown} / 共 ${total} 条`,
|
||||
pinFolder: "置顶文件夹",
|
||||
unpinFolder: "取消置顶文件夹",
|
||||
pinSession: "置顶会话",
|
||||
unpinSession: "取消置顶会话",
|
||||
allSessions: "全部会话",
|
||||
folders: "工作文件夹",
|
||||
selectSession: "选择会话",
|
||||
archived: "已归档",
|
||||
internal: "内部",
|
||||
internalHint: "内部任务仅供查看,不参与会话同步。",
|
||||
pending: "待同步",
|
||||
unknownProvider: "未知供应商",
|
||||
noModel: "未记录",
|
||||
noMatch: "没有匹配的会话。",
|
||||
noSessions: "还没有读取到会话。点击右上角“检查会话”刷新。",
|
||||
loadingMore: "正在加载更多会话…",
|
||||
diagnostics: "诊断信息",
|
||||
diagnosticsCount: (count: number) => `${count} 条 · 点击查看`,
|
||||
deleteTitle: (count: number) => `永久删除 ${count} 条会话`,
|
||||
@@ -214,7 +276,7 @@ export function SessionManagementPage({
|
||||
: {
|
||||
syncEyebrow: "SESSION SYNC",
|
||||
title: "Session management",
|
||||
description: "Keep local sessions in one history shared by official and third-party providers. Chat content is not changed.",
|
||||
description: "Keep user conversations in one shared history. Internal tasks are excluded from sync; chat content is unchanged.",
|
||||
syncTo: "Sync to",
|
||||
check: "Check sessions",
|
||||
checking: "Checking...",
|
||||
@@ -226,11 +288,11 @@ export function SessionManagementPage({
|
||||
allSynced: "All sessions are synced",
|
||||
sessionCount: (count: number) => `${count} sessions`,
|
||||
local: "LOCAL SESSIONS",
|
||||
list: "Sessions",
|
||||
list: "Folders & sessions",
|
||||
shown: (shown: number, total: number) => `${shown} / ${total} shown`,
|
||||
loaded: (count: number) => `${count} loaded`,
|
||||
search: "Search title / project / provider / ID",
|
||||
groupByProject: "Group by project path",
|
||||
groupByProject: "Folder view",
|
||||
showInternal: (count: number) => `Show internal sessions (${count})`,
|
||||
deleteSelected: "Delete selected",
|
||||
exportSelected: "Export selected",
|
||||
@@ -242,14 +304,22 @@ export function SessionManagementPage({
|
||||
selectProject: (path: string, count: number) => `Select ${count} sessions in ${path}`,
|
||||
projectCount: (count: number, truncated: boolean) => `${count}${truncated ? " loaded" : ""}`,
|
||||
projectShown: (shown: number, total: number) => `${shown} / ${total} shown`,
|
||||
pinFolder: "Pin folder",
|
||||
unpinFolder: "Unpin folder",
|
||||
pinSession: "Pin session",
|
||||
unpinSession: "Unpin session",
|
||||
allSessions: "All sessions",
|
||||
folders: "Workspace folders",
|
||||
selectSession: "Select session",
|
||||
archived: "Archived",
|
||||
internal: "Internal",
|
||||
internalHint: "Internal tasks are available for inspection and excluded from session sync.",
|
||||
pending: "Needs sync",
|
||||
unknownProvider: "Unknown provider",
|
||||
noModel: "Not recorded",
|
||||
noMatch: "No matching sessions.",
|
||||
noSessions: "No sessions loaded. Click Check sessions to refresh.",
|
||||
loadingMore: "Loading more sessions…",
|
||||
diagnostics: "Diagnostics",
|
||||
diagnosticsCount: (count: number) => `${count} · click to view`,
|
||||
deleteTitle: (count: number) => `Permanently delete ${count} session(s)`,
|
||||
@@ -271,10 +341,173 @@ export function SessionManagementPage({
|
||||
...(sessionStatus?.warnings || []).map((message) => ({ message, blocking: false })),
|
||||
];
|
||||
const dialogOpen = sessionDeleteConfirmOpen && selectedSessions.length > 0;
|
||||
const selectedVisibleCount = filteredSessions.filter((item) => selectedSessionSet.has(item.id)).length;
|
||||
const allVisibleSelected = filteredSessions.length > 0 && selectedVisibleCount === filteredSessions.length;
|
||||
const sessionScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const directoryIdentities = sessionStatus?.directoryIdentities;
|
||||
const pinScopeKey = sessionStatus?.pinScopeKey;
|
||||
const pinStorageKey = useMemo(() => sessionPinStorageKey(codexDir, pinScopeKey), [codexDir, pinScopeKey]);
|
||||
const [pinPreferences, setPinPreferences] = useState<ScopedSessionPinPreferences>({
|
||||
storageKey: "",
|
||||
folders: [],
|
||||
sessions: [],
|
||||
});
|
||||
const [selectedFolderKey, setSelectedFolderKey] = useState(SESSION_ALL_FOLDERS_KEY);
|
||||
const [sessionScrollMetrics, setSessionScrollMetrics] = useState({
|
||||
scrollTop: 0,
|
||||
viewportHeight: DEFAULT_SESSION_VIEWPORT_HEIGHT,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPinPreferences({ storageKey: pinStorageKey, ...readSessionPinPreferences(pinStorageKey, sessionPinStorage) });
|
||||
setSelectedFolderKey(SESSION_ALL_FOLDERS_KEY);
|
||||
}, [pinStorageKey]);
|
||||
|
||||
const activePinPreferences = pinPreferences.storageKey === pinStorageKey
|
||||
? pinPreferences
|
||||
: { storageKey: pinStorageKey, folders: [], sessions: [] };
|
||||
const pinnedFolderSet = useMemo(() => pinnedSessionDirectoryKeys(activePinPreferences.folders, directoryIdentities), [activePinPreferences.folders, directoryIdentities]);
|
||||
const pinnedSessionSet = useMemo(() => new Set(activePinPreferences.sessions), [activePinPreferences.sessions]);
|
||||
|
||||
const updatePins = useCallback((kind: "folders" | "sessions", key: string) => {
|
||||
setPinPreferences((current) => {
|
||||
const base = current.storageKey === pinStorageKey
|
||||
? current
|
||||
: { storageKey: pinStorageKey, ...readSessionPinPreferences(pinStorageKey, sessionPinStorage) };
|
||||
const values = new Set(base.sessions);
|
||||
if (kind === "sessions") {
|
||||
if (values.has(key)) values.delete(key);
|
||||
else values.add(key);
|
||||
}
|
||||
const next = kind === "folders"
|
||||
? { ...base, folders: toggleFolderPins(base.folders, key, directoryIdentities) }
|
||||
: { ...base, sessions: Array.from(values) };
|
||||
try {
|
||||
localStorage.setItem(pinStorageKey, JSON.stringify({ folders: next.folders, sessions: next.sessions }));
|
||||
} catch {
|
||||
// Pinning remains available for this run even when localStorage is unavailable.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [pinStorageKey, directoryIdentities]);
|
||||
|
||||
const orderedSessionGroups = useMemo(() => mergeSessionDirectoryGroups(groupedSessions, directoryIdentities).map(({ group, folderKey, items }) => {
|
||||
const orderedItems = [...items].sort((left, right) => {
|
||||
const pinnedOrder = Number(pinnedSessionSet.has(right.id)) - Number(pinnedSessionSet.has(left.id));
|
||||
if (pinnedOrder) return pinnedOrder;
|
||||
const recencyOrder = (right.updatedAtMs || 0) - (left.updatedAtMs || 0);
|
||||
return recencyOrder || left.id.localeCompare(right.id);
|
||||
});
|
||||
return {
|
||||
group,
|
||||
folderKey,
|
||||
items: orderedItems,
|
||||
newestAt: orderedItems.reduce((latest, item) => Math.max(latest, item.updatedAtMs || 0), 0),
|
||||
pinned: pinnedFolderSet.has(folderKey),
|
||||
};
|
||||
}).sort((left, right) => {
|
||||
if (!sessionGroupByCwd) return 0;
|
||||
const pinnedOrder = Number(right.pinned) - Number(left.pinned);
|
||||
return pinnedOrder || right.newestAt - left.newestAt || left.group.localeCompare(right.group);
|
||||
}), [groupedSessions, directoryIdentities, pinnedFolderSet, pinnedSessionSet, sessionGroupByCwd]);
|
||||
|
||||
const orderedAllSessions = useMemo(() => [...filteredSessions].sort((left, right) => {
|
||||
const pinnedOrder = Number(pinnedSessionSet.has(right.id)) - Number(pinnedSessionSet.has(left.id));
|
||||
if (pinnedOrder) return pinnedOrder;
|
||||
const recencyOrder = (right.updatedAtMs || 0) - (left.updatedAtMs || 0);
|
||||
return recencyOrder || left.id.localeCompare(right.id);
|
||||
}), [filteredSessions, pinnedSessionSet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionGroupByCwd || selectedFolderKey === SESSION_ALL_FOLDERS_KEY) return;
|
||||
if (!orderedSessionGroups.some((group) => group.folderKey === selectedFolderKey)) {
|
||||
setSelectedFolderKey(SESSION_ALL_FOLDERS_KEY);
|
||||
}
|
||||
}, [orderedSessionGroups, selectedFolderKey, sessionGroupByCwd]);
|
||||
|
||||
const displayedSessions = useMemo(() => {
|
||||
if (!sessionGroupByCwd || selectedFolderKey === SESSION_ALL_FOLDERS_KEY) return orderedAllSessions;
|
||||
return sessionsForDirectoryGroup(orderedSessionGroups, selectedFolderKey);
|
||||
}, [orderedAllSessions, orderedSessionGroups, selectedFolderKey, sessionGroupByCwd]);
|
||||
const selectedVisibleCount = displayedSessions.filter((item) => selectedSessionSet.has(item.id)).length;
|
||||
const allVisibleSelected = displayedSessions.length > 0 && selectedVisibleCount === displayedSessions.length;
|
||||
const visibleSelectionIsPartial = selectedVisibleCount > 0 && !allVisibleSelected;
|
||||
|
||||
const virtualSessionModel = useMemo(() => {
|
||||
const rows: SessionVirtualRow[] = [];
|
||||
let top = 0;
|
||||
displayedSessions.forEach((item) => {
|
||||
rows.push({
|
||||
kind: "session",
|
||||
key: `session:${item.id}`,
|
||||
top,
|
||||
height: SESSION_ROW_HEIGHT,
|
||||
item,
|
||||
});
|
||||
top += SESSION_ROW_HEIGHT;
|
||||
});
|
||||
return { rows, totalHeight: top };
|
||||
}, [displayedSessions]);
|
||||
|
||||
const visibleSessionRows = useMemo(() => {
|
||||
const viewportHeight = sessionScrollMetrics.viewportHeight || DEFAULT_SESSION_VIEWPORT_HEIGHT;
|
||||
const bodyScrollTop = Math.max(0, sessionScrollMetrics.scrollTop - SESSION_COLUMN_HEADER_HEIGHT);
|
||||
const firstVisibleIndex = findVirtualRowIndex(virtualSessionModel.rows, bodyScrollTop);
|
||||
const lastVisibleIndex = findVirtualRowIndex(virtualSessionModel.rows, bodyScrollTop + viewportHeight);
|
||||
const firstIndex = Math.max(0, firstVisibleIndex - SESSION_VIRTUAL_OVERSCAN_ROWS);
|
||||
const lastIndex = Math.min(
|
||||
virtualSessionModel.rows.length,
|
||||
lastVisibleIndex + SESSION_VIRTUAL_OVERSCAN_ROWS + 1,
|
||||
);
|
||||
return virtualSessionModel.rows.slice(firstIndex, lastIndex);
|
||||
}, [sessionScrollMetrics, virtualSessionModel]);
|
||||
|
||||
const syncSessionScrollMetrics = useCallback(() => {
|
||||
const element = sessionScrollRef.current;
|
||||
if (!element) return;
|
||||
setSessionScrollMetrics((current) => {
|
||||
const next = {
|
||||
scrollTop: element.scrollTop,
|
||||
viewportHeight: element.clientHeight,
|
||||
};
|
||||
return current.scrollTop === next.scrollTop && current.viewportHeight === next.viewportHeight
|
||||
? current
|
||||
: next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSessionScroll = useCallback(() => {
|
||||
syncSessionScrollMetrics();
|
||||
}, [syncSessionScrollMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
syncSessionScrollMetrics();
|
||||
const element = sessionScrollRef.current;
|
||||
if (!element) return undefined;
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
window.addEventListener("resize", syncSessionScrollMetrics);
|
||||
return () => window.removeEventListener("resize", syncSessionScrollMetrics);
|
||||
}
|
||||
const observer = new ResizeObserver(syncSessionScrollMetrics);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [active, syncSessionScrollMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = sessionScrollRef.current;
|
||||
if (!element) return;
|
||||
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
|
||||
if (element.scrollTop > maxScrollTop) {
|
||||
element.scrollTop = maxScrollTop;
|
||||
syncSessionScrollMetrics();
|
||||
}
|
||||
}, [syncSessionScrollMetrics, virtualSessionModel.totalHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = sessionScrollRef.current;
|
||||
if (!element) return;
|
||||
element.scrollTop = 0;
|
||||
syncSessionScrollMetrics();
|
||||
}, [selectedFolderKey, sessionGroupByCwd, syncSessionScrollMetrics]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalShell
|
||||
@@ -400,6 +633,7 @@ export function SessionManagementPage({
|
||||
className={cx("cx-session-toggle", showInternalSessions && "cx-session-toggle--active")}
|
||||
checked={showInternalSessions}
|
||||
onCheckedChange={onShowInternalSessionsChange}
|
||||
title={copy.internalHint}
|
||||
label={copy.showInternal(sessionStatus?.subagentThreads ?? 0)}
|
||||
/>
|
||||
)}
|
||||
@@ -426,56 +660,102 @@ export function SessionManagementPage({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filteredSessions.length > 0 ? (
|
||||
<div className="cx-session-scroll" role="table" aria-label={copy.list}>
|
||||
<div className="cx-session-column-head" role="row">
|
||||
<Checkbox
|
||||
className="cx-session-select-all"
|
||||
checked={allVisibleSelected}
|
||||
indeterminate={visibleSelectionIsPartial}
|
||||
onCheckedChange={(checked) => onSetSessionGroupSelected(filteredSessions, checked)}
|
||||
aria-label={copy.selectAll}
|
||||
disabled={loading || sessionDeleteBusy}
|
||||
/>
|
||||
<span>{isChinese ? "会话" : "Session"}</span>
|
||||
<span>{isChinese ? "更新时间" : "Updated"}</span>
|
||||
<span>{isChinese ? "供应商" : "Provider"}</span>
|
||||
<span>{isChinese ? "模型" : "Model"}</span>
|
||||
<span className="cx-session-id-heading">ID</span>
|
||||
<span className="cx-session-actions-heading">{isChinese ? "导出" : "Export"}</span>
|
||||
</div>
|
||||
<div className="cx-session-table-body">
|
||||
{groupedSessions.map(([group, items]) => {
|
||||
const projectSessions = allSessionsByCwd.get(group) || items;
|
||||
const selectedProjectCount = projectSessions.filter((item) => selectedSessionSet.has(item.id)).length;
|
||||
const projectSelected = projectSessions.length > 0 && selectedProjectCount === projectSessions.length;
|
||||
const projectPartiallySelected = selectedProjectCount > 0 && !projectSelected;
|
||||
const groupCountLabel = items.length === projectSessions.length
|
||||
? copy.projectCount(projectSessions.length, sessionPreviewTruncated)
|
||||
: copy.projectShown(items.length, projectSessions.length);
|
||||
return (
|
||||
<div className="cx-session-group" key={group}>
|
||||
{sessionGroupByCwd && (
|
||||
<label className={cx("cx-session-group-heading", projectSelected && "cx-session-group-heading--selected", projectPartiallySelected && "cx-session-group-heading--partial")}>
|
||||
<input
|
||||
className="cx-session-checkbox"
|
||||
type="checkbox"
|
||||
ref={(input) => {
|
||||
if (input) input.indeterminate = projectPartiallySelected;
|
||||
}}
|
||||
checked={projectSelected}
|
||||
onChange={(event) => onSetSessionGroupSelected(projectSessions, event.target.checked)}
|
||||
aria-label={copy.selectProject(group, projectSessions.length)}
|
||||
/>
|
||||
<span title={group}>{compactPath(group, 96, isChinese ? "未记录路径" : "No path recorded")}</span>
|
||||
<em>{groupCountLabel}</em>
|
||||
</label>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<div className={cx("cx-session-browser", !sessionGroupByCwd && "cx-session-browser--flat")}>
|
||||
{sessionGroupByCwd && (
|
||||
<aside className="cx-session-folder-tree" aria-label={copy.folders}>
|
||||
<div className="cx-session-tree-heading">
|
||||
<ChevronDown size={14} strokeWidth={2} aria-hidden="true" />
|
||||
<FolderTree size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
<span>{copy.folders}</span>
|
||||
</div>
|
||||
<div className="cx-session-tree-items" role="tree">
|
||||
<button
|
||||
type="button"
|
||||
role="treeitem"
|
||||
aria-selected={selectedFolderKey === SESSION_ALL_FOLDERS_KEY}
|
||||
className={cx("cx-session-tree-all", selectedFolderKey === SESSION_ALL_FOLDERS_KEY && "cx-session-tree-item--active")}
|
||||
onClick={() => setSelectedFolderKey(SESSION_ALL_FOLDERS_KEY)}
|
||||
>
|
||||
<History size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
<span>{copy.allSessions}</span>
|
||||
<em>{filteredSessions.length}</em>
|
||||
</button>
|
||||
{orderedSessionGroups.map((folder) => (
|
||||
<div
|
||||
className={cx("cx-session-tree-item", selectedFolderKey === folder.folderKey && "cx-session-tree-item--active")}
|
||||
role="treeitem"
|
||||
aria-selected={selectedFolderKey === folder.folderKey}
|
||||
key={folder.folderKey}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="cx-session-tree-select"
|
||||
onClick={() => setSelectedFolderKey(folder.folderKey)}
|
||||
title={folder.group}
|
||||
>
|
||||
<Folder size={15} strokeWidth={1.8} aria-hidden="true" />
|
||||
<span className="cx-session-folder-copy">
|
||||
<strong>{folderDisplayName(folder.group, isChinese ? "未记录路径" : "No path recorded")}</strong>
|
||||
<small>{compactPath(folder.group, 34, isChinese ? "未记录路径" : "No path recorded")}</small>
|
||||
</span>
|
||||
<em>{folder.items.length}</em>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cx("cx-session-pin-action", folder.pinned && "cx-session-pin-action--active")}
|
||||
onClick={() => updatePins("folders", folder.items.find((item) => item.cwd)?.cwd || "")}
|
||||
aria-label={folder.pinned ? copy.unpinFolder : copy.pinFolder}
|
||||
title={folder.pinned ? copy.unpinFolder : copy.pinFolder}
|
||||
>
|
||||
<Pin size={13} strokeWidth={1.9} fill={folder.pinned ? "currentColor" : "none"} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<div className="cx-session-results">
|
||||
{displayedSessions.length > 0 ? (
|
||||
<div
|
||||
ref={sessionScrollRef}
|
||||
className="cx-session-scroll"
|
||||
role="table"
|
||||
aria-label={copy.list}
|
||||
onScroll={handleSessionScroll}
|
||||
>
|
||||
<div className="cx-session-column-head" role="row">
|
||||
<Checkbox
|
||||
className="cx-session-select-all"
|
||||
checked={allVisibleSelected}
|
||||
indeterminate={visibleSelectionIsPartial}
|
||||
onCheckedChange={(checked) => onSetSessionGroupSelected(displayedSessions, checked)}
|
||||
aria-label={copy.selectAll}
|
||||
disabled={loading || sessionDeleteBusy}
|
||||
/>
|
||||
<span>{isChinese ? "会话" : "Session"}</span>
|
||||
<span>{isChinese ? "更新时间" : "Updated"}</span>
|
||||
<span>{isChinese ? "供应商" : "Provider"}</span>
|
||||
<span>{isChinese ? "模型" : "Model"}</span>
|
||||
<span className="cx-session-id-heading">ID</span>
|
||||
<span className="cx-session-actions-heading">{isChinese ? "操作" : "Actions"}</span>
|
||||
</div>
|
||||
<div className="cx-session-table-body" style={{ height: virtualSessionModel.totalHeight }}>
|
||||
{visibleSessionRows.map((row) => {
|
||||
const { item } = row;
|
||||
const sessionPinned = pinnedSessionSet.has(item.id);
|
||||
return (
|
||||
<div
|
||||
className={cx("cx-session-row", item.needsSync && "cx-session-row--needs-sync", selectedSessionSet.has(item.id) && "cx-session-row--selected")}
|
||||
key={item.id}
|
||||
className={cx(
|
||||
"cx-session-row",
|
||||
"cx-session-virtual-item",
|
||||
item.needsSync && !item.isSubagent && "cx-session-row--needs-sync",
|
||||
selectedSessionSet.has(item.id) && "cx-session-row--selected",
|
||||
sessionPinned && "cx-session-row--pinned",
|
||||
)}
|
||||
key={row.key}
|
||||
role="row"
|
||||
style={{ top: row.top, height: row.height }}
|
||||
onClick={(event) => {
|
||||
if (!(event.target as HTMLElement).closest("button, input") && !loading && !sessionDeleteBusy) onToggleSessionSelected(item.id);
|
||||
}}
|
||||
@@ -494,38 +774,49 @@ export function SessionManagementPage({
|
||||
<div className="cx-session-row-title">
|
||||
<strong title={item.title}>{item.title || (isChinese ? "未命名会话" : "Untitled session")}</strong>
|
||||
{item.archived && <span className="cx-session-state">{copy.archived}</span>}
|
||||
{item.isSubagent && <span className="cx-session-state">{copy.internal}</span>}
|
||||
{item.needsSync && <span className="cx-session-state cx-session-state--warn">{copy.pending}</span>}
|
||||
{item.isSubagent && <span className="cx-session-state" title={copy.internalHint}>{copy.internal}</span>}
|
||||
{item.needsSync && !item.isSubagent && <span className="cx-session-state cx-session-state--warn">{copy.pending}</span>}
|
||||
</div>
|
||||
{!sessionGroupByCwd && <p title={item.cwd || item.rolloutPath || undefined}>{compactPath(item.cwd || item.rolloutPath, 72, isChinese ? "未记录路径" : "No path recorded")}</p>}
|
||||
{(!sessionGroupByCwd || selectedFolderKey === SESSION_ALL_FOLDERS_KEY) && <p title={item.cwd || item.rolloutPath || undefined}>{compactPath(item.cwd || item.rolloutPath, 72, isChinese ? "未记录路径" : "No path recorded")}</p>}
|
||||
</div>
|
||||
<span className="cx-session-meta cx-session-meta--time" title={item.updatedAtMs ? new Date(item.updatedAtMs).toLocaleString() : undefined}>{formatSessionTime(item.updatedAtMs, lang)}</span>
|
||||
<code className="cx-session-meta cx-session-meta--provider" title={item.modelProvider || undefined}>{item.modelProvider || copy.unknownProvider}</code>
|
||||
<span className="cx-session-meta cx-session-meta--model" title={item.model || undefined}>{item.model || copy.noModel}</span>
|
||||
<small className="cx-session-meta cx-session-meta--id" title={item.id}>#{shortId(item.id)}</small>
|
||||
<button
|
||||
type="button"
|
||||
className="cx-session-row-export"
|
||||
onClick={() => onExportSessions([item.id])}
|
||||
disabled={loading || sessionDeleteBusy || sessionExportBusy}
|
||||
aria-label={`${copy.exportOne}: ${item.title || (isChinese ? "未命名会话" : "Untitled session")}`}
|
||||
title={copy.exportOne}
|
||||
>
|
||||
<Download size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
</button>
|
||||
<div className="cx-session-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={cx("cx-session-pin-action", sessionPinned && "cx-session-pin-action--active")}
|
||||
onClick={() => updatePins("sessions", item.id)}
|
||||
aria-label={sessionPinned ? copy.unpinSession : copy.pinSession}
|
||||
title={sessionPinned ? copy.unpinSession : copy.pinSession}
|
||||
>
|
||||
<Pin size={14} strokeWidth={1.9} fill={sessionPinned ? "currentColor" : "none"} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cx-session-row-export"
|
||||
onClick={() => onExportSessions([item.id])}
|
||||
disabled={loading || sessionDeleteBusy || sessionExportBusy}
|
||||
aria-label={`${copy.exportOne}: ${item.title || (isChinese ? "未命名会话" : "Untitled session")}`}
|
||||
title={copy.exportOne}
|
||||
>
|
||||
<Download size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cx-session-empty">
|
||||
<History size={22} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>{sessionQuery ? copy.noMatch : copy.noSessions}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="cx-session-empty">
|
||||
<History size={22} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>{sessionQuery ? copy.noMatch : copy.noSessions}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{diagnostics.length ? (
|
||||
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
SlidersHorizontal,
|
||||
Shuffle,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { PageTransition } from "../components/PageTransition";
|
||||
import { Button, ModalShell } from "../components/ui";
|
||||
import { UsageStatisticsPage } from "./UsageStatisticsPage";
|
||||
import { ProviderFailoverPage } from "./ProviderFailoverPage";
|
||||
import "../styles/utility-pages.css";
|
||||
|
||||
export type UtilityLanguage = "zh" | "en";
|
||||
@@ -114,18 +116,25 @@ export type SettingsPageProps = {
|
||||
restartBusy?: boolean;
|
||||
generalRequest?: number;
|
||||
configHealthStatus?: ReactNode;
|
||||
onChange?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
// PageTransition keeps the previous content during its exit animation. Context
|
||||
// still propagates the current activity state so requests stop immediately,
|
||||
// while the retained usage component keeps its filters between tabs.
|
||||
const SettingsUsageActiveContext = createContext(false);
|
||||
const SettingsFailoverActiveContext = createContext(false);
|
||||
|
||||
function SettingsUsagePanel({ lang, configDir }: Pick<SettingsPageProps, "lang" | "configDir">) {
|
||||
const active = useContext(SettingsUsageActiveContext);
|
||||
return <UsageStatisticsPage lang={lang} configDir={configDir} active={active} />;
|
||||
}
|
||||
|
||||
function SettingsFailoverPanel({ lang, configDir, onChange }: Pick<SettingsPageProps, "lang" | "configDir" | "onChange">) {
|
||||
const active = useContext(SettingsFailoverActiveContext);
|
||||
return <ProviderFailoverPage lang={lang} configDir={configDir} active={active} onChange={onChange} />;
|
||||
}
|
||||
|
||||
type SettingRowProps = {
|
||||
icon: LucideIcon;
|
||||
title: ReactNode;
|
||||
@@ -160,10 +169,12 @@ export function SettingsPage({
|
||||
restartBusy = false,
|
||||
generalRequest = 0,
|
||||
configHealthStatus,
|
||||
onChange,
|
||||
}: SettingsPageProps) {
|
||||
const [tab, setTab] = useState<"general" | "usage">("general");
|
||||
const [tab, setTab] = useState<"general" | "usage" | "failover">("general");
|
||||
useEffect(() => { setTab("general"); }, [generalRequest]);
|
||||
const [usageOpened, setUsageOpened] = useState(false);
|
||||
const [failoverOpened, setFailoverOpened] = useState(false);
|
||||
const tabId = useId();
|
||||
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
|
||||
@@ -179,19 +190,20 @@ export function SettingsPage({
|
||||
<section className="cx-utility cx-page cx-page--settings">
|
||||
<PageHeader eyebrow={copy.eyebrow} title={copy.title} />
|
||||
<div className="cx-settings-tabs" role="tablist" aria-label={lang === "zh" ? "设置页面" : "Settings pages"}>
|
||||
{(["general", "usage"] as const).map((value, index) => {
|
||||
const Icon = value === "general" ? SlidersHorizontal : BarChart3;
|
||||
const select = () => { setTab(value); if (value === "usage") setUsageOpened(true); };
|
||||
{(["general", "usage", "failover"] as const).map((value, index, tabs) => {
|
||||
const Icon = value === "general" ? SlidersHorizontal : value === "usage" ? BarChart3 : Shuffle;
|
||||
const select = () => { setTab(value); if (value === "usage") setUsageOpened(true); if (value === "failover") setFailoverOpened(true); };
|
||||
return <button key={value} ref={(element) => { tabRefs.current[index] = element; }} type="button" role="tab" id={`${tabId}-${value}-tab`} aria-controls={`${tabId}-${value}-panel`} aria-selected={tab === value} tabIndex={tab === value ? 0 : -1} className="cx-settings-tab" onClick={select} onKeyDown={(event) => {
|
||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? 1 : 1 - index;
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (index + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
|
||||
tabRefs.current[next]?.click();
|
||||
tabRefs.current[next]?.focus();
|
||||
}}><Icon size={14} aria-hidden="true" />{value === "general" ? (lang === "zh" ? "通用设置" : "General") : (lang === "zh" ? "用量统计" : "Usage statistics")}</button>;
|
||||
}}><Icon size={14} aria-hidden="true" />{value === "general" ? (lang === "zh" ? "通用设置" : "General") : value === "usage" ? (lang === "zh" ? "用量统计" : "Usage statistics") : (lang === "zh" ? "路由与故障转移" : "Routing & failover")}</button>;
|
||||
})}
|
||||
</div>
|
||||
<SettingsUsageActiveContext.Provider value={active && tab === "usage"}>
|
||||
<SettingsFailoverActiveContext.Provider value={active && tab === "failover"}>
|
||||
<PageTransition pageKey={`settings:${tab}`}>
|
||||
<div className="cx-settings-panel cx-page-settings-list" role="tabpanel" id={`${tabId}-general-panel`} aria-labelledby={`${tabId}-general-tab`} hidden={tab !== "general"}>
|
||||
<SettingRow
|
||||
@@ -268,7 +280,11 @@ export function SettingsPage({
|
||||
<div className="cx-settings-panel" role="tabpanel" id={`${tabId}-usage-panel`} aria-labelledby={`${tabId}-usage-tab`} hidden={tab !== "usage"}>
|
||||
{usageOpened && <SettingsUsagePanel lang={lang} configDir={configDir} />}
|
||||
</div>
|
||||
<div className="cx-settings-panel" role="tabpanel" id={`${tabId}-failover-panel`} aria-labelledby={`${tabId}-failover-tab`} hidden={tab !== "failover"}>
|
||||
{failoverOpened && <SettingsFailoverPanel lang={lang} configDir={configDir} onChange={onChange} />}
|
||||
</div>
|
||||
</PageTransition>
|
||||
</SettingsFailoverActiveContext.Provider>
|
||||
</SettingsUsageActiveContext.Provider>
|
||||
|
||||
<ModalShell
|
||||
|
||||
@@ -2,6 +2,33 @@ type OrderedProviderRow = {
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
type IdentifiedProviderRow = { source: string; id: string };
|
||||
|
||||
export function providerRowKey(row: IdentifiedProviderRow): string {
|
||||
return `${row.source}:${row.id}`;
|
||||
}
|
||||
|
||||
export function applyProviderRowOrder<Row extends IdentifiedProviderRow>(rows: readonly Row[], order: readonly string[]): Row[] {
|
||||
const remaining = new Map(rows.map((row) => [providerRowKey(row), row]));
|
||||
const sorted: Row[] = [];
|
||||
for (const key of order) {
|
||||
const row = remaining.get(key);
|
||||
if (row) {
|
||||
sorted.push(row);
|
||||
remaining.delete(key);
|
||||
}
|
||||
}
|
||||
return [...sorted, ...remaining.values()];
|
||||
}
|
||||
|
||||
export function moveProviderRow(order: readonly string[], source: string, target: string, position: "before" | "after"): string[] {
|
||||
if (source === target || !order.includes(source) || !order.includes(target)) return [...order];
|
||||
const next = order.filter((key) => key !== source);
|
||||
const targetIndex = next.indexOf(target);
|
||||
next.splice(targetIndex + (position === "after" ? 1 : 0), 0, source);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function orderProviderRows<
|
||||
Official extends OrderedProviderRow,
|
||||
Detected extends OrderedProviderRow,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Parameter ranges and save/reset semantics adapted from CC Switch (MIT).
|
||||
// Copyright (c) 2025 Jason Young. See THIRD_PARTY_NOTICES.md.
|
||||
export const routingFields = [
|
||||
{ key: "maxRetries", min: 0, max: 10, value: 3, group: "retry", zh: "最大重试次数", en: "Maximum retries", hintZh: "失败后最多再尝试几家供应商,0 表示不重试。", hintEn: "Additional providers to try after a failure. 0 disables retries." },
|
||||
{ key: "circuitFailureThreshold", min: 1, max: 20, value: 4, group: "retry", zh: "连续失败阈值", en: "Consecutive failures", hintZh: "连续失败达到此次数后,暂时跳过该供应商。", hintEn: "Temporarily skip a provider after this many consecutive failures." },
|
||||
{ key: "streamingFirstByteTimeout", min: 1, max: 120, value: 60, group: "timeout", zh: "流式首字节超时", en: "First response timeout", hintZh: "等待开始返回内容的时间,单位为秒。", hintEn: "Seconds to wait for the first response data." },
|
||||
{ key: "streamingIdleTimeout", min: 0, max: 600, value: 120, group: "timeout", zh: "流式静默超时", en: "Stream idle timeout", hintZh: "回复过程中等待下一段内容的秒数;0 表示不限制。", hintEn: "Seconds between response chunks. 0 disables this timeout." },
|
||||
{ key: "nonStreamingTimeout", min: 60, max: 1200, value: 600, group: "timeout", zh: "非流式总超时", en: "Non-streaming timeout", hintZh: "等待完整回复的最长时间,单位为秒。", hintEn: "Total seconds to wait for a complete non-streaming response." },
|
||||
{ key: "circuitSuccessThreshold", min: 1, max: 10, value: 2, group: "recovery", zh: "恢复成功次数", en: "Recovery successes", hintZh: "试探恢复时,连续成功几次后恢复正常。", hintEn: "Successful trial requests required to restore normal routing." },
|
||||
{ key: "circuitTimeoutSeconds", min: 0, max: 300, value: 60, group: "recovery", zh: "恢复等待时间", en: "Recovery wait", hintZh: "暂时跳过后,隔多少秒再尝试。", hintEn: "Seconds to wait before trying an unavailable provider again." },
|
||||
{ key: "circuitErrorRateThreshold", min: 0, max: 100, value: 60, group: "recovery", zh: "错误率阈值 (%)", en: "Error rate threshold (%)", hintZh: "达到此比例时,也会暂时跳过供应商。", hintEn: "Also skip a provider when its failure rate reaches this percentage." },
|
||||
{ key: "circuitMinRequests", min: 5, max: 100, value: 10, group: "recovery", zh: "最小请求数", en: "Minimum requests", hintZh: "累计达到此请求数后,才开始判断错误率。", hintEn: "Requests required before evaluating the error rate." },
|
||||
] as const;
|
||||
|
||||
export type RoutingField = typeof routingFields[number]["key"];
|
||||
export type RoutingSettings = Record<RoutingField, number> & {
|
||||
version: number; routerEnabled: boolean; takeoverEnabled: boolean; autoFailoverEnabled: boolean;
|
||||
listenAddress: string; listenPort: number; providerIds: string[];
|
||||
};
|
||||
export type RoutingDraft = Omit<RoutingSettings, RoutingField | "listenPort"> & Record<RoutingField | "listenPort", string>;
|
||||
|
||||
export function routingDraft(settings: RoutingSettings): RoutingDraft {
|
||||
const values = Object.fromEntries(routingFields.map(({ key }) => [key, String(key === "circuitErrorRateThreshold" ? Number((settings[key] * 100).toFixed(6)) : settings[key])])) as Record<RoutingField, string>;
|
||||
return { ...settings, ...values, listenPort: String(settings.listenPort), providerIds: [...settings.providerIds] };
|
||||
}
|
||||
|
||||
export function sameRoutingDraft(a: RoutingDraft, b: RoutingDraft): boolean {
|
||||
return a.routerEnabled === b.routerEnabled && a.takeoverEnabled === b.takeoverEnabled
|
||||
&& a.autoFailoverEnabled === b.autoFailoverEnabled && a.listenAddress === b.listenAddress && a.listenPort === b.listenPort
|
||||
&& routingFields.every(({ key }) => a[key] === b[key])
|
||||
&& a.providerIds.length === b.providerIds.length && a.providerIds.every((id, index) => id === b.providerIds[index]);
|
||||
}
|
||||
|
||||
export function parseRoutingDraft(draft: RoutingDraft): { settings: RoutingSettings | null; errors: Partial<Record<RoutingField | "listenAddress" | "listenPort" | "providerIds", string>> } {
|
||||
const errors: ReturnType<typeof parseRoutingDraft>["errors"] = {};
|
||||
const numbers = {} as Record<RoutingField, number>;
|
||||
for (const field of routingFields) {
|
||||
const text = draft[field.key].trim();
|
||||
const pattern = field.key === "circuitErrorRateThreshold" ? /^\d+(?:\.\d+)?$/ : /^\d+$/;
|
||||
const value = Number(text);
|
||||
if (!pattern.test(text) || !Number.isFinite(value) || value < field.min || value > field.max) errors[field.key] = `${field.min}–${field.max}`;
|
||||
numbers[field.key] = field.key === "circuitErrorRateThreshold" ? value / 100 : value;
|
||||
}
|
||||
let address = draft.listenAddress.trim();
|
||||
if (address.toLowerCase() === "localhost") address = "127.0.0.1";
|
||||
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(address) && address.split(".").every((part) => Number(part) <= 255 && String(Number(part)) === part);
|
||||
let ipv6 = false;
|
||||
if (address.includes(":")) {
|
||||
try { ipv6 = new URL(`http://[${address}]/`).hostname.startsWith("["); } catch { /* invalid literal */ }
|
||||
}
|
||||
if (!ipv4 && !ipv6) errors.listenAddress = "IPv4 / IPv6 / localhost";
|
||||
const port = Number(draft.listenPort);
|
||||
if (!/^\d+$/.test(draft.listenPort.trim()) || port < 1024 || port > 65535) errors.listenPort = "1024–65535";
|
||||
if (draft.providerIds.length > 64 || new Set(draft.providerIds).size !== draft.providerIds.length) errors.providerIds = "1–64";
|
||||
return { errors, settings: Object.keys(errors).length ? null : {
|
||||
...draft, ...numbers, version: 2, listenAddress: address, listenPort: port, providerIds: [...draft.providerIds],
|
||||
} };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export const SESSION_FOLDER_UNKNOWN_KEY = "__codexx_no_workspace__";
|
||||
const SESSION_PINS_STORAGE_PREFIX = "codexx.sessionPins.v2";
|
||||
|
||||
export type SessionDirectoryIdentities = Readonly<Record<string, string>>;
|
||||
export type SessionPinPreferences = { folders: string[]; sessions: string[] };
|
||||
export type SessionDirectoryGroup<T> = { group: string; folderKey: string; items: T[] };
|
||||
|
||||
/** Only the backend can confirm that differently spelled paths name one directory. */
|
||||
export function sessionDirectoryKey(
|
||||
path?: string | null,
|
||||
identities?: SessionDirectoryIdentities,
|
||||
): string {
|
||||
if (path == null || path === "") return SESSION_FOLDER_UNKNOWN_KEY;
|
||||
if (identities && Object.prototype.hasOwnProperty.call(identities, path)) {
|
||||
return identities[path];
|
||||
}
|
||||
// Missing/offline directories keep their exact spelling. Case, backslashes,
|
||||
// roots and spaces can all distinguish legitimate paths on some filesystems.
|
||||
return `path:${path}`;
|
||||
}
|
||||
|
||||
export function sessionPinStorageKey(codexDir: string, verifiedScope?: string | null) {
|
||||
// v1 discarded case and cannot safely assign its pins to a particular home.
|
||||
// A snapshot's file ID alone is not a permanent home identity after deletion.
|
||||
return `${SESSION_PINS_STORAGE_PREFIX}:${encodeURIComponent(verifiedScope || sessionDirectoryKey(codexDir))}`;
|
||||
}
|
||||
|
||||
export function pinnedSessionDirectoryKeys(folders: readonly string[], identities?: SessionDirectoryIdentities): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const stored of folders) {
|
||||
if (stored === SESSION_FOLDER_UNKNOWN_KEY) keys.add(stored);
|
||||
else if (stored.startsWith("path:")) keys.add(sessionDirectoryKey(stored.slice(5), identities));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** Persist original paths so offline/recreated folders retain their pins. */
|
||||
export function toggleFolderPins(
|
||||
folders: readonly string[],
|
||||
path?: string | null,
|
||||
identities?: SessionDirectoryIdentities,
|
||||
): string[] {
|
||||
const currentKey = sessionDirectoryKey(path, identities);
|
||||
const belongsToCurrentFolder = (stored: string) => pinnedSessionDirectoryKeys([stored], identities).has(currentKey);
|
||||
if (folders.some(belongsToCurrentFolder)) return folders.filter((stored) => !belongsToCurrentFolder(stored));
|
||||
return [...new Set([...folders, sessionDirectoryKey(path)])];
|
||||
}
|
||||
|
||||
export function readSessionPinPreferences(
|
||||
storageKey: string,
|
||||
storage: Pick<Storage, "getItem">,
|
||||
): SessionPinPreferences {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(storageKey) || "{}") as Partial<SessionPinPreferences>;
|
||||
return {
|
||||
folders: Array.isArray(parsed.folders) ? parsed.folders.filter((value): value is string => typeof value === "string") : [],
|
||||
sessions: Array.isArray(parsed.sessions) ? parsed.sessions.filter((value): value is string => typeof value === "string") : [],
|
||||
};
|
||||
} catch {
|
||||
return { folders: [], sessions: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeSessionDirectoryGroups<T extends { cwd?: string | null }>(
|
||||
groups: ReadonlyArray<readonly [string, readonly T[]]>,
|
||||
identities?: SessionDirectoryIdentities,
|
||||
): SessionDirectoryGroup<T>[] {
|
||||
const result = new Map<string, SessionDirectoryGroup<T>>();
|
||||
for (const [group, items] of groups) {
|
||||
const folderKey = sessionDirectoryKey(items.find((item) => item.cwd)?.cwd, identities);
|
||||
const existing = result.get(folderKey);
|
||||
if (existing) existing.items.push(...items);
|
||||
else result.set(folderKey, { group, folderKey, items: [...items] });
|
||||
}
|
||||
return [...result.values()];
|
||||
}
|
||||
|
||||
export function sessionsForDirectoryGroup<T>(groups: ReadonlyArray<SessionDirectoryGroup<T>>, key: string): T[] {
|
||||
return groups.find((group) => group.folderKey === key)?.items || [];
|
||||
}
|
||||
@@ -224,6 +224,12 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.cx-update-notes .cx-update-log-path {
|
||||
margin-top: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
@keyframes cx-update-progress-indeterminate {
|
||||
from { transform: translateX(-110%); }
|
||||
to { transform: translateX(300%); }
|
||||
|
||||
@@ -603,10 +603,56 @@
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-folder-tree,
|
||||
:root[data-theme="dark"] .cx-session-tree-heading {
|
||||
color: var(--cx-dark-muted);
|
||||
background: var(--cx-dark-surface-raised);
|
||||
border-color: var(--cx-dark-border);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-results {
|
||||
background: var(--cx-dark-surface);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-tree-all,
|
||||
:root[data-theme="dark"] .cx-session-tree-item {
|
||||
color: var(--cx-dark-muted);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-tree-all:hover,
|
||||
:root[data-theme="dark"] .cx-session-tree-item:hover {
|
||||
color: var(--cx-dark-text);
|
||||
background: var(--cx-dark-surface-hover);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-tree-item--active {
|
||||
color: var(--cx-dark-blue);
|
||||
background: var(--cx-dark-blue-surface);
|
||||
border-color: #5879aa;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-tree-item--active .cx-session-folder-copy strong {
|
||||
color: var(--cx-dark-blue);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-group + .cx-session-group {
|
||||
border-top-color: var(--cx-dark-surface-recessed);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-group-heading--selected,
|
||||
:root[data-theme="dark"] .cx-session-group-heading--partial {
|
||||
color: var(--cx-dark-blue);
|
||||
background: var(--cx-dark-blue-surface);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-folder-copy strong {
|
||||
color: var(--cx-dark-text);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-folder-copy small {
|
||||
color: var(--cx-dark-muted);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-row {
|
||||
background: var(--cx-dark-surface);
|
||||
border-color: var(--cx-dark-border);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
.cx-failover {
|
||||
--rf-text: #202a3a; --rf-muted: #566378; --rf-border: #ced7e4; --rf-surface: #ffffff; --rf-inset: #f4f7fb; --rf-input: #ffffff;
|
||||
--rf-blue: #215adb; --rf-blue-bg: #edf3ff; --rf-blue-border: #a4bff3;
|
||||
--rf-green: #17643f; --rf-green-bg: #e9f8ef; --rf-green-border: #9cceb0;
|
||||
--rf-amber: #815109; --rf-amber-bg: #fff5df; --rf-amber-border: #e3c27b;
|
||||
--rf-red: #b32d37; --rf-red-bg: #fff0f0; --rf-red-border: #e2a6aa;
|
||||
display: grid; gap: 18px; max-width: 1120px; color: var(--rf-text); font-size: 14px;
|
||||
}
|
||||
:root[data-theme="dark"] .cx-failover {
|
||||
--rf-text: #f1f5fb; --rf-muted: #c0cad9; --rf-border: #525e70; --rf-surface: #303744; --rf-inset: #272e39; --rf-input: #202731;
|
||||
--rf-blue: #a3c4ff; --rf-blue-bg: #263e65; --rf-blue-border: #6089ca;
|
||||
--rf-green: #a2e6b9; --rf-green-bg: #233e31; --rf-green-border: #4e8162;
|
||||
--rf-amber: #f5d191; --rf-amber-bg: #453927; --rf-amber-border: #957647;
|
||||
--rf-red: #ffb3bc; --rf-red-bg: #4b2c35; --rf-red-border: #9b5d6b;
|
||||
}
|
||||
.cx-failover h3, .cx-failover h4, .cx-failover h5, .cx-failover p { margin: 0; }
|
||||
.cx-failover h3 { color: var(--rf-text); font-size: 20px; font-weight: 750; }
|
||||
.cx-failover h4 { color: var(--rf-text); font-size: 15px; font-weight: 720; }
|
||||
.cx-failover-heading { display: flex; justify-content: space-between; gap: 20px; align-items: center; }
|
||||
.cx-failover-heading p { margin-top: 6px; color: var(--rf-muted); line-height: 1.6; font-size: 13px; }
|
||||
.cx-failover-card { overflow: hidden; border: 1px solid var(--rf-border); border-radius: 13px; background: var(--rf-surface); box-shadow: 0 3px 12px #00000006; }
|
||||
.cx-failover-card-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 16px 20px; border-bottom: 1px solid var(--rf-border); background: var(--rf-inset); }
|
||||
.cx-failover-card-heading h4 { display: flex; align-items: center; gap: 9px; }
|
||||
.cx-failover-card-heading h4 svg { color: var(--rf-blue); }
|
||||
.cx-failover-section-note { color: var(--rf-muted); font-size: 12px; }
|
||||
.cx-failover-toggle-row { display: flex; align-items: center; gap: 13px; padding: 19px 20px; }
|
||||
.cx-failover-toggle-copy { min-width: 0; flex: 1; }
|
||||
.cx-failover-toggle-copy label { display: inline-block; color: var(--rf-text); font-size: 14px; font-weight: 710; cursor: pointer; }
|
||||
.cx-failover-toggle-copy p { margin-top: 5px; line-height: 1.6; color: var(--rf-muted); font-size: 12px; }
|
||||
.cx-failover-symbol { display: grid; place-items: center; flex: 0 0 40px; width: 40px; height: 40px; color: var(--rf-blue); background: var(--rf-blue-bg); border: 1px solid var(--rf-blue-border); border-radius: 11px; }
|
||||
.cx-failover-switch { position: relative; flex: 0 0 44px; width: 44px; height: 26px; border: 1px solid #738198; border-radius: 20px; background: #78859a; cursor: pointer; transition: background-color 130ms ease; }
|
||||
.cx-failover-switch span { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 4px #0003; transition: transform 130ms ease; }
|
||||
.cx-failover-switch[aria-checked="true"] { background: #2866ed; border-color: #2866ed; }
|
||||
.cx-failover-switch[aria-checked="true"] span { transform: translateX(18px); }
|
||||
.cx-failover-switch:disabled { cursor: not-allowed; border-style: dashed; }
|
||||
.cx-failover-switch:disabled:not([aria-checked="true"]) { background: #697383; }
|
||||
.cx-failover-switch:focus-visible, .cx-failover-candidate:focus-visible, .cx-failover input:focus-visible { outline: 2px solid var(--rf-blue); outline-offset: 3px; }
|
||||
.cx-failover-address-grid { display: grid; grid-template-columns: 1.5fr 1fr; gap: 16px; padding: 0 20px 18px 73px; border-bottom: 1px solid var(--rf-border); }
|
||||
.cx-failover-address-grid label, .cx-failover-fields label { display: grid; gap: 8px; align-content: start; color: var(--rf-text); font-size: 13px; font-weight: 650; }
|
||||
.cx-failover input { box-sizing: border-box; width: 100%; min-width: 0; height: 39px; padding: 8px 11px; color: var(--rf-text); background: var(--rf-input); border: 1px solid var(--rf-border); border-radius: 7px; font: inherit; font-size: 14px; font-variant-numeric: tabular-nums; }
|
||||
.cx-failover input:disabled { color: var(--rf-muted); background: var(--rf-inset); cursor: not-allowed; }
|
||||
.cx-failover input[aria-invalid="true"] { border-color: var(--rf-red); box-shadow: 0 0 0 1px var(--rf-red); }
|
||||
.cx-failover label small { font-size: 12px; font-weight: 400; color: var(--rf-muted); line-height: 1.55; }
|
||||
.cx-failover .cx-failover-field-error { color: var(--rf-red); }
|
||||
.cx-failover p.cx-failover-inline-hint { padding: 0 20px 16px; color: var(--rf-muted); line-height: 1.6; font-size: 12px; }
|
||||
.cx-failover-primary { display: flex; gap: 12px; align-items: center; margin: 0 20px 18px; padding: 14px; border-radius: 9px; border: 1px solid var(--rf-border); background: var(--rf-inset); }
|
||||
.cx-failover-provider-copy { flex: 1; min-width: 0; display: grid; gap: 5px; }
|
||||
.cx-failover-provider-copy strong, .cx-failover-candidate strong { color: var(--rf-text); font-size: 14px; font-weight: 740; overflow-wrap: anywhere; }
|
||||
.cx-failover-provider-copy small, .cx-failover-candidate small { color: var(--rf-muted); font-size: 12px; line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cx-failover-label { color: var(--rf-muted); font-size: 12px; }
|
||||
.cx-failover-state { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; flex: 0 0 auto; border-radius: 7px; color: var(--rf-muted); background: var(--rf-inset); border: 1px solid var(--rf-border); font-size: 12px; font-weight: 650; }
|
||||
.cx-failover-state i, .cx-failover-health i { width: 6px; height: 6px; flex: 0 0 6px; border-radius: 50%; background: currentColor; }
|
||||
.cx-failover-state--running { color: var(--rf-green); background: var(--rf-green-bg); border-color: var(--rf-green-border); }
|
||||
.cx-failover-service-address { display: flex; gap: 12px; align-items: center; padding: 0 20px 18px; color: var(--rf-muted); font-size: 12px; }
|
||||
.cx-failover-service-address code { padding: 6px 10px; border-radius: 6px; background: var(--rf-inset); color: var(--rf-text); overflow-wrap: anywhere; }
|
||||
.cx-failover-queue-heading { display: flex; align-items: flex-start; gap: 16px; justify-content: space-between; padding: 3px 20px 14px; }
|
||||
.cx-failover-queue-heading p { margin-top: 7px; font-size: 12px; color: var(--rf-muted); line-height: 1.6; }
|
||||
.cx-failover-queue-heading > span { color: var(--rf-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.cx-failover-empty { display: grid; gap: 8px; margin: 0 20px; padding: 20px; border-radius: 9px; border: 1px dashed var(--rf-border); background: var(--rf-inset); }
|
||||
.cx-failover-empty strong { font-size: 14px; font-weight: 650; }
|
||||
.cx-failover-empty span { color: var(--rf-muted); font-size: 12px; line-height: 1.6; }
|
||||
.cx-failover-queue { display: grid; gap: 9px; list-style: none; margin: 0 20px; padding: 0; }
|
||||
.cx-failover-queue > li { display: flex; align-items: center; gap: 12px; padding: 13px; border: 1px solid var(--rf-border); border-radius: 9px; background: var(--rf-surface); }
|
||||
.cx-failover-queue > li.cx-failover-provider--first { border-color: var(--rf-blue-border); background: var(--rf-blue-bg); }
|
||||
.cx-failover-order { display: grid; place-items: center; flex: 0 0 34px; width: 34px; height: 30px; border-radius: 7px; color: var(--rf-muted); background: var(--rf-inset); font-size: 12px; font-weight: 750; }
|
||||
.cx-failover-provider--first .cx-failover-order { background: #2866ed; color: #fff; }
|
||||
.cx-failover-queue > li.cx-failover-provider--invalid { border-color: var(--rf-red-border); }
|
||||
.cx-failover-provider--invalid .cx-failover-provider-copy small { color: var(--rf-red); white-space: normal; }
|
||||
.cx-failover-row-actions { display: flex; gap: 4px; flex: 0 0 auto; }
|
||||
.cx-failover-health { display: inline-flex; align-items: center; flex: 0 0 auto; gap: 5px; padding: 5px 8px; border-radius: 7px; border: 1px solid var(--rf-border); color: var(--rf-muted); background: var(--rf-inset); font-size: 12px; font-weight: 620; }
|
||||
.cx-failover-health--closed { background: var(--rf-green-bg); color: var(--rf-green); border-color: var(--rf-green-border); }
|
||||
.cx-failover-health--half_open { background: var(--rf-amber-bg); color: var(--rf-amber); border-color: var(--rf-amber-border); }
|
||||
.cx-failover-health--open { background: var(--rf-red-bg); color: var(--rf-red); border-color: var(--rf-red-border); }
|
||||
.cx-failover-candidates { padding: 18px 20px 20px; }
|
||||
.cx-failover-candidates > p { padding-top: 10px; font-size: 12px; line-height: 1.6; color: var(--rf-muted); }
|
||||
.cx-failover-candidate-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 11px; }
|
||||
.cx-failover-candidate { display: flex; justify-content: space-between; align-items: center; gap: 12px; min-width: 0; padding: 12px; border: 1px solid var(--rf-border); border-radius: 8px; text-align: left; background: var(--rf-inset); color: var(--rf-text); cursor: pointer; }
|
||||
.cx-failover-candidate > div { min-width: 0; display: grid; gap: 5px; }
|
||||
.cx-failover-candidate > svg { flex: 0 0 auto; color: var(--rf-blue); }
|
||||
.cx-failover-candidate:hover:not(:disabled) { border-color: var(--rf-blue); background: var(--rf-blue-bg); }
|
||||
.cx-failover-candidate:disabled { cursor: not-allowed; border-style: dashed; }
|
||||
.cx-failover-candidate:disabled svg { color: var(--rf-muted); }
|
||||
.cx-failover-field-group { padding: 18px 20px 20px; }
|
||||
.cx-failover-field-group + .cx-failover-field-group { border-top: 1px solid var(--rf-border); }
|
||||
.cx-failover-field-group h5 { color: var(--rf-blue); margin-bottom: 12px; font-size: 13px; font-weight: 700; }
|
||||
.cx-failover-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
|
||||
.cx-failover-field-group--timeout .cx-failover-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.cx-failover-field-group--recovery .cx-failover-fields { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.cx-failover-field-input { position: relative; }
|
||||
.cx-failover-field-input input { padding-right: 68px; }
|
||||
.cx-failover-field-input > span { position: absolute; right: 24px; top: 11px; color: var(--rf-muted); font-size: 11px; font-weight: 400; pointer-events: none; }
|
||||
.cx-failover-runtime dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin: 0; padding: 20px; }
|
||||
.cx-failover-runtime dt { color: var(--rf-muted); font-size: 12px; }
|
||||
.cx-failover-runtime dd { margin: 8px 0 0; font-size: 23px; font-weight: 740; font-variant-numeric: tabular-nums; }
|
||||
.cx-failover-runtime dd span { color: var(--rf-muted); font-size: 18px; font-weight: 500; }
|
||||
.cx-failover-runtime-current { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 14px 20px; border-top: 1px solid var(--rf-border); color: var(--rf-muted); font-size: 12px; }
|
||||
.cx-failover-runtime-current strong { color: var(--rf-text); font-size: 14px; }
|
||||
.cx-failover-runtime-current time { margin-left: auto; }
|
||||
.cx-failover-runtime p.cx-failover-runtime-note { display: flex; gap: 7px; margin: 0; padding: 0 20px 16px; color: var(--rf-muted); line-height: 1.65; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.cx-failover-runtime-note svg { flex: 0 0 auto; margin-top: 2px; }
|
||||
.cx-failover-message { display: flex; align-items: center; gap: 9px; padding: 13px 15px; border: 1px solid var(--rf-border); border-radius: 9px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.cx-failover-message svg { flex: 0 0 auto; }
|
||||
.cx-failover-message > div { flex: 1; min-width: 0; display: grid; gap: 3px; }
|
||||
.cx-failover-message--warning { color: var(--rf-amber); background: var(--rf-amber-bg); border-color: var(--rf-amber-border); }
|
||||
.cx-failover-message--success { color: var(--rf-green); background: var(--rf-green-bg); border-color: var(--rf-green-border); }
|
||||
.cx-failover-bottom-note { color: var(--rf-muted); font-size: 12px; line-height: 1.7; }
|
||||
.cx-failover-save { position: sticky; bottom: 0; z-index: 2; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 14px 18px; border: 1px solid var(--rf-border); border-radius: 10px; background: var(--rf-surface); box-shadow: 0 -4px 18px #0000000a; }
|
||||
.cx-failover-save > div:first-child { color: var(--rf-muted); font-size: 12px; line-height: 1.6; }
|
||||
.cx-failover-save > div:first-child.cx-failover-field-error { color: var(--rf-red); }
|
||||
.cx-failover-save > div:last-child { display: flex; flex: 0 0 auto; gap: 8px; }
|
||||
.cx-failover-loading { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 180px; color: var(--rf-muted); font-size: 14px; }
|
||||
.cx-failover-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); }
|
||||
@media (max-width: 850px) {
|
||||
.cx-failover-field-group--recovery .cx-failover-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cx-failover-queue > li { flex-wrap: wrap; }
|
||||
.cx-failover-queue .cx-failover-provider-copy { flex-basis: calc(100% - 52px); }
|
||||
.cx-failover-row-actions { margin-left: auto; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.cx-failover-address-grid { padding-left: 20px; grid-template-columns: 1fr; }
|
||||
.cx-failover-candidate-list, .cx-failover-fields, .cx-failover-field-group--timeout .cx-failover-fields { grid-template-columns: 1fr; }
|
||||
.cx-failover-primary { flex-wrap: wrap; }
|
||||
.cx-failover-service-address { align-items: flex-start; flex-direction: column; }
|
||||
.cx-failover-runtime dl { grid-template-columns: 1fr 1fr; }
|
||||
.cx-failover-save { align-items: stretch; flex-direction: column; }
|
||||
.cx-failover-save > div:last-child { justify-content: flex-end; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .cx-failover-switch, .cx-failover-switch span { transition: none; } }
|
||||
@@ -200,7 +200,7 @@
|
||||
min-width: 0;
|
||||
min-height: 78px;
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||
grid-template-columns: 18px 42px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
@@ -225,9 +225,23 @@
|
||||
/* Give official account emails their own centered column. Current-state
|
||||
badges can change the actions without moving the email in any row. */
|
||||
.cx-providers-row--official {
|
||||
grid-template-columns: 42px minmax(0, 1fr) minmax(200px, 300px) minmax(310px, 1fr);
|
||||
grid-template-columns: 18px 42px minmax(0, 1fr) minmax(200px, 300px) minmax(310px, 1fr);
|
||||
}
|
||||
|
||||
.cx-providers-sort-hint { margin: -7px 1px 12px; color: var(--cx-provider-muted); font-size: 11px; line-height: 1.5; }
|
||||
.cx-providers-drag-handle { display: grid; place-items: center; width: 22px; height: 34px; margin-left: -2px; padding: 0; border: 0; border-radius: 5px; background: transparent; color: var(--cx-provider-muted); cursor: grab; touch-action: none; user-select: none; }
|
||||
.cx-providers-drag-handle:hover:not(:disabled) { background: var(--cx-provider-blue-soft); color: var(--cx-provider-blue); }
|
||||
.cx-providers-drag-handle:active { cursor: grabbing; }
|
||||
.cx-providers-drag-handle:focus-visible { outline: 2px solid var(--cx-provider-blue); outline-offset: 2px; }
|
||||
.cx-providers-drag-handle:disabled { opacity: .35; cursor: default; }
|
||||
.cx-providers-row--dragging { opacity: .45; }
|
||||
.cx-providers-row--drop-before { box-shadow: inset 0 3px 0 var(--cx-provider-blue); }
|
||||
.cx-providers-row--drop-after { box-shadow: inset 0 -3px 0 var(--cx-provider-blue); }
|
||||
.cx-providers-row-model { display: flex; align-items: baseline; gap: 6px; margin-top: 5px; min-width: 0; font-size: 11px; line-height: 1.5; }
|
||||
.cx-providers-row-model>span { flex-shrink: 0; color: var(--cx-provider-muted); }
|
||||
.cx-providers-row-model>b { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--cx-provider-text); }
|
||||
.cx-providers-row-model>code { margin: 0; flex: 0 1 auto; }
|
||||
|
||||
.cx-providers-row--official > .cx-providers-login-badge {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
@@ -915,17 +929,17 @@
|
||||
@media (max-width: 760px) {
|
||||
.cx-providers-row,
|
||||
.cx-providers-row--official {
|
||||
grid-template-columns: 40px minmax(0, 1fr);
|
||||
grid-template-columns: 18px 40px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-providers-row--official > .cx-providers-login-badge {
|
||||
grid-column: 2;
|
||||
grid-column: 3;
|
||||
width: min(300px, 100%);
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.cx-providers-row-actions {
|
||||
grid-column: 2;
|
||||
grid-column: 3;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
|
||||
@@ -387,6 +387,146 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cx-session-browser {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 0;
|
||||
flex: 1 1 0;
|
||||
display: grid;
|
||||
grid-template-columns: 238px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cx-session-browser--flat {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-session-folder-tree {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid #e6e9ed;
|
||||
background: #f8f9fb;
|
||||
}
|
||||
|
||||
.cx-session-tree-heading {
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 11px;
|
||||
border-bottom: 1px solid #e6e9ed;
|
||||
color: #767e89;
|
||||
background: #fafbfc;
|
||||
font-size: 10px;
|
||||
font-weight: 720;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cx-session-tree-items {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
padding: 7px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.cx-session-tree-all,
|
||||
.cx-session-tree-item {
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
color: #626a75;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cx-session-tree-all {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 17px minmax(0, 1fr) auto;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 690;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cx-session-tree-all em,
|
||||
.cx-session-tree-select em {
|
||||
color: #969da7;
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.cx-session-tree-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 27px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cx-session-tree-select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: 17px minmax(0, 1fr) auto;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
padding: 4px 3px 4px 8px;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cx-session-tree-item > .cx-session-pin-action {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cx-session-tree-all:hover,
|
||||
.cx-session-tree-item:hover {
|
||||
color: #354052;
|
||||
background: #f0f3f7;
|
||||
}
|
||||
|
||||
.cx-session-tree-item--active {
|
||||
border-color: #cfdfff;
|
||||
color: #2454b3;
|
||||
background: #edf4ff;
|
||||
}
|
||||
|
||||
.cx-session-tree-item--active .cx-session-folder-copy strong {
|
||||
color: #2454b3;
|
||||
}
|
||||
|
||||
.cx-session-tree-all:focus-visible,
|
||||
.cx-session-tree-select:focus-visible {
|
||||
outline: 2px solid var(--ui-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.cx-session-results {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.cx-session-scroll {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -426,7 +566,7 @@
|
||||
.cx-session-column-head,
|
||||
.cx-session-row {
|
||||
display: grid;
|
||||
grid-template-columns: 26px minmax(200px, 1fr) 104px 104px 112px 104px 34px;
|
||||
grid-template-columns: 26px minmax(200px, 1fr) 104px 104px 112px 104px 70px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -456,9 +596,16 @@
|
||||
}
|
||||
|
||||
.cx-session-table-body {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cx-session-virtual-item {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.cx-session-group {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -470,17 +617,20 @@
|
||||
.cx-session-group-heading {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 26px minmax(0, 1fr) auto;
|
||||
grid-template-columns: 26px minmax(0, 1fr) auto 30px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid #eef0f2;
|
||||
color: #737b86;
|
||||
background: #fcfcfd;
|
||||
font-size: 11px;
|
||||
font-weight: 680;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cx-session-group-heading--separated {
|
||||
border-top: 7px solid #f7f8f9;
|
||||
}
|
||||
|
||||
.cx-session-group-heading--selected,
|
||||
@@ -489,13 +639,61 @@
|
||||
background: #f5f8ff;
|
||||
}
|
||||
|
||||
.cx-session-group-heading > span {
|
||||
.cx-session-group-heading--pinned {
|
||||
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--ui-accent) 72%, transparent);
|
||||
}
|
||||
|
||||
.cx-session-folder-toggle {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cx-session-folder-toggle > svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cx-session-folder-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.cx-session-folder-copy strong,
|
||||
.cx-session-folder-copy small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cx-session-folder-copy strong {
|
||||
color: #3f4650;
|
||||
font-size: 11.5px;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.cx-session-folder-copy small {
|
||||
color: #9299a3;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 9.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cx-session-folder-toggle:focus-visible {
|
||||
border-radius: 4px;
|
||||
outline: 2px solid var(--ui-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cx-session-group-heading em {
|
||||
color: #969ca5;
|
||||
font-size: 10px;
|
||||
@@ -534,6 +732,14 @@
|
||||
background: #edf4ff;
|
||||
}
|
||||
|
||||
.cx-session-row--pinned:not(.cx-session-row--selected) {
|
||||
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--ui-accent) 52%, transparent);
|
||||
}
|
||||
|
||||
.cx-session-row--nested .cx-session-row-copy {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.cx-session-row:focus-within {
|
||||
outline: 1px solid rgba(37, 99, 235, 0.38);
|
||||
outline-offset: -1px;
|
||||
@@ -664,7 +870,15 @@
|
||||
color: #4669a8;
|
||||
}
|
||||
|
||||
.cx-session-row-export {
|
||||
.cx-session-row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cx-session-row-export,
|
||||
.cx-session-pin-action {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
justify-self: end;
|
||||
@@ -678,22 +892,44 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cx-session-row-export:hover:not(:disabled) {
|
||||
.cx-session-row-export:hover:not(:disabled),
|
||||
.cx-session-pin-action:hover:not(:disabled) {
|
||||
color: var(--ui-accent);
|
||||
border-color: var(--ui-accent);
|
||||
background: var(--ui-accent-soft);
|
||||
}
|
||||
|
||||
.cx-session-row-export:focus-visible {
|
||||
.cx-session-row-export:focus-visible,
|
||||
.cx-session-pin-action:focus-visible {
|
||||
outline: 2px solid var(--ui-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cx-session-row-export:disabled {
|
||||
.cx-session-row-export:disabled,
|
||||
.cx-session-pin-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cx-session-pin-action--active {
|
||||
color: var(--ui-accent);
|
||||
border-color: color-mix(in srgb, var(--ui-accent) 42%, var(--ui-border));
|
||||
background: var(--ui-accent-soft);
|
||||
}
|
||||
|
||||
.cx-session-load-more {
|
||||
min-height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid #eef0f2;
|
||||
color: #7e8793;
|
||||
background: #fafbfc;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cx-session-empty {
|
||||
min-height: 120px;
|
||||
flex: 1 1 auto;
|
||||
@@ -911,9 +1147,17 @@
|
||||
}
|
||||
|
||||
@media (max-width: 1240px) {
|
||||
.cx-session-browser {
|
||||
grid-template-columns: 210px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-session-browser--flat {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-session-column-head,
|
||||
.cx-session-row {
|
||||
grid-template-columns: 26px minmax(180px, 1fr) 96px 96px 104px 34px;
|
||||
grid-template-columns: 26px minmax(180px, 1fr) 96px 96px 104px 70px;
|
||||
}
|
||||
|
||||
.cx-session-id-heading,
|
||||
@@ -957,9 +1201,23 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.cx-session-browser {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(110px, 30%) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-session-browser--flat {
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-session-folder-tree {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #e6e9ed;
|
||||
}
|
||||
|
||||
.cx-session-column-head,
|
||||
.cx-session-row {
|
||||
grid-template-columns: 26px minmax(0, 1fr) 82px 34px;
|
||||
grid-template-columns: 26px minmax(0, 1fr) 82px 70px;
|
||||
}
|
||||
|
||||
.cx-session-column-head span:nth-child(n + 4):not(.cx-session-actions-heading),
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { AppUpdaterController, isAppUpdateBusy, UPDATER_SLOW_NOTICE_MS } from "../src/appUpdaterController.ts";
|
||||
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
function fixture() {
|
||||
const counts = { checks: 0, installs: 0, restarts: 0, closes: 0 };
|
||||
const timers = new Map();
|
||||
let timerId = 0;
|
||||
let update = { rid: 12, currentVersion: "0.3.20", version: "0.3.21", body: "Update notes", close: async () => { counts.closes++; } };
|
||||
let operation;
|
||||
let checkOperation;
|
||||
let restartOperation;
|
||||
const controller = new AppUpdaterController({
|
||||
check: async () => { counts.checks++; return checkOperation ? checkOperation.promise : update; },
|
||||
install: (value, onEvent, options) => {
|
||||
counts.installs++;
|
||||
operation = { ...deferred(), value, onEvent, options };
|
||||
return operation.promise;
|
||||
},
|
||||
restart: () => { counts.restarts++; return restartOperation?.promise || Promise.resolve(); },
|
||||
setTimer: (callback, delay) => { assert.equal(delay, UPDATER_SLOW_NOTICE_MS); timers.set(++timerId, callback); return timerId; },
|
||||
clearTimer: (id) => { timers.delete(id); },
|
||||
});
|
||||
return {
|
||||
controller, counts, timers,
|
||||
get operation() { return operation; },
|
||||
setCheckOperation(value) { checkOperation = value; },
|
||||
setRestartOperation(value) { restartOperation = value; },
|
||||
setUpdate(value) { update = value; },
|
||||
waitLonger() { for (const [id, callback] of timers) { timers.delete(id); callback(); } },
|
||||
};
|
||||
}
|
||||
|
||||
test("download, verification and shutdown preparation are distinct; ready requires installation result", async () => {
|
||||
const f = fixture();
|
||||
assert.equal(await f.controller.check(), "available");
|
||||
const job = f.controller.downloadAndInstall({ timeout: 4000 });
|
||||
assert.equal(f.operation.options.timeout, 4000);
|
||||
f.operation.onEvent({ event: "Started", data: { contentLength: 100 } });
|
||||
f.operation.onEvent({ event: "Progress", data: { chunkLength: 100 } });
|
||||
assert.equal(f.controller.getSnapshot().phase, "downloading");
|
||||
for (const [event, phase] of [["Verifying", "verifying"], ["Preparing", "preparing"], ["Installing", "installing"]]) {
|
||||
f.operation.onEvent({ event });
|
||||
assert.equal(f.controller.getSnapshot().phase, phase);
|
||||
assert.equal(isAppUpdateBusy(phase), true);
|
||||
}
|
||||
f.operation.resolve({ restartRequired: true });
|
||||
assert.equal(await job, "ready");
|
||||
assert.equal(f.controller.getSnapshot().phase, "ready");
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
|
||||
test("clicking repeatedly and checking during an update cannot launch a second installer", async () => {
|
||||
const f = fixture();
|
||||
await f.controller.check();
|
||||
const job = f.controller.downloadAndInstall();
|
||||
assert.equal(f.controller.downloadAndInstall(), job);
|
||||
assert.equal(f.controller.retry(), job);
|
||||
assert.equal(await f.controller.check({ force: true }), "available");
|
||||
assert.equal(f.counts.checks, 1);
|
||||
f.operation.onEvent({ event: "Preparing" });
|
||||
assert.equal(f.controller.downloadAndInstall(), job);
|
||||
f.operation.resolve({ restartRequired: true });
|
||||
await job;
|
||||
await f.controller.downloadAndInstall();
|
||||
await f.controller.check({ force: true });
|
||||
assert.equal(f.counts.installs, 1);
|
||||
assert.equal(f.counts.checks, 1);
|
||||
assert.equal(f.counts.closes, 0);
|
||||
});
|
||||
|
||||
test("preparation failure is not success; shows safe details and allows a deliberate retry", async () => {
|
||||
const f = fixture();
|
||||
await f.controller.check();
|
||||
const job = f.controller.downloadAndInstall();
|
||||
f.operation.onEvent({ event: "Preparing" });
|
||||
f.operation.reject({ stage: "prepare", message: "无法恢复连接配置,尚未启动安装程序。", logPath: "C:\\Logs\\update.log" });
|
||||
assert.equal(await job, "error");
|
||||
assert.equal(f.controller.getSnapshot().failure, "prepare");
|
||||
assert.equal(f.controller.getSnapshot().errorMessage, "无法恢复连接配置,尚未启动安装程序。");
|
||||
assert.equal(f.controller.getSnapshot().logPath, "C:\\Logs\\update.log");
|
||||
assert.equal(f.timers.size, 0);
|
||||
const retry = f.controller.retry();
|
||||
assert.equal(f.counts.installs, 2);
|
||||
assert.equal(f.controller.getSnapshot().errorMessage, null);
|
||||
f.operation.resolve({ restartRequired: true });
|
||||
assert.equal(await retry, "ready");
|
||||
});
|
||||
|
||||
test("signature rejection never displays success or exposes an arbitrary plugin error", async () => {
|
||||
const f = fixture();
|
||||
await f.controller.check();
|
||||
const job = f.controller.downloadAndInstall();
|
||||
f.operation.onEvent({ event: "Verifying" });
|
||||
f.operation.reject(new Error("signature failed at https://private.example.test?token=secret"));
|
||||
assert.equal(await job, "error");
|
||||
assert.equal(f.controller.getSnapshot().failure, "verify");
|
||||
assert.equal(f.controller.getSnapshot().errorMessage, null);
|
||||
});
|
||||
|
||||
test("successful handoff followed by an IPC disconnect must not offer or start another installation", async () => {
|
||||
const f = fixture();
|
||||
await f.controller.check();
|
||||
const job = f.controller.downloadAndInstall();
|
||||
f.operation.onEvent({ event: "HandedOff" });
|
||||
f.operation.reject(new Error("IPC disconnected as the app exits"));
|
||||
assert.equal(await job, "handed-off");
|
||||
assert.equal(f.controller.getSnapshot().failure, null);
|
||||
assert.equal(isAppUpdateBusy(f.controller.getSnapshot().phase), true);
|
||||
assert.equal(await f.controller.retry(), "handed-off");
|
||||
assert.equal(await f.controller.restart(), "handed-off");
|
||||
f.waitLonger();
|
||||
assert.equal(f.controller.getSnapshot().takingLonger, true);
|
||||
assert.equal(f.counts.installs, 1);
|
||||
assert.equal(f.counts.restarts, 0);
|
||||
});
|
||||
|
||||
test("handoff result without restart remains pending installer completion, not installed", async () => {
|
||||
const f = fixture();
|
||||
await f.controller.check();
|
||||
const job = f.controller.downloadAndInstall();
|
||||
f.operation.resolve({ restartRequired: false });
|
||||
assert.equal(await job, "handed-off");
|
||||
assert.equal(await f.controller.downloadAndInstall(), "handed-off");
|
||||
assert.equal(f.counts.installs, 1);
|
||||
});
|
||||
|
||||
test("slow notices never abort a pending operation and new progress clears the notice", async () => {
|
||||
const f = fixture();
|
||||
await f.controller.check();
|
||||
const job = f.controller.downloadAndInstall();
|
||||
f.waitLonger();
|
||||
assert.equal(f.controller.getSnapshot().takingLonger, true);
|
||||
assert.equal(f.controller.getSnapshot().phase, "downloading");
|
||||
assert.equal(f.controller.retry(), job);
|
||||
f.operation.onEvent({ event: "Progress", data: { chunkLength: 12 } });
|
||||
assert.equal(f.controller.getSnapshot().takingLonger, false);
|
||||
f.operation.onEvent({ event: "Preparing" });
|
||||
f.waitLonger();
|
||||
assert.equal(f.controller.getSnapshot().takingLonger, true);
|
||||
assert.equal(f.controller.getSnapshot().phase, "preparing");
|
||||
assert.equal(f.counts.installs, 1);
|
||||
f.operation.resolve({ restartRequired: true });
|
||||
await job;
|
||||
assert.equal(f.controller.getSnapshot().takingLonger, false);
|
||||
});
|
||||
|
||||
test("late events from a failed attempt do not corrupt the retried download", async () => {
|
||||
const f = fixture();
|
||||
await f.controller.check();
|
||||
const first = f.controller.downloadAndInstall();
|
||||
const old = f.operation;
|
||||
old.reject({ stage: "download", message: "Network unavailable" });
|
||||
await first;
|
||||
const second = f.controller.retry();
|
||||
old.onEvent({ event: "HandedOff" });
|
||||
old.onEvent({ event: "Progress", data: { chunkLength: 900 } });
|
||||
assert.equal(f.controller.getSnapshot().phase, "downloading");
|
||||
assert.equal(f.controller.getSnapshot().downloadedBytes, 0);
|
||||
f.operation.resolve({ restartRequired: true });
|
||||
await second;
|
||||
});
|
||||
|
||||
test("only a confirmed ready update can relaunch, and failed relaunch retries only relaunch", async () => {
|
||||
const f = fixture();
|
||||
await f.controller.check();
|
||||
assert.equal(await f.controller.restart(), "available");
|
||||
const job = f.controller.downloadAndInstall();
|
||||
f.operation.resolve({ restartRequired: true });
|
||||
await job;
|
||||
const failed = deferred();
|
||||
f.setRestartOperation(failed);
|
||||
const restart = f.controller.restart();
|
||||
failed.reject(new Error("blocked"));
|
||||
assert.equal(await restart, "error");
|
||||
assert.equal(f.controller.getSnapshot().failure, "restart");
|
||||
f.setRestartOperation(null);
|
||||
assert.equal(await f.controller.retry(), "ready");
|
||||
assert.equal(f.controller.getSnapshot().phase, "ready");
|
||||
assert.equal(f.counts.restarts, 2);
|
||||
assert.equal(f.counts.installs, 1);
|
||||
});
|
||||
|
||||
test("a pending forced check cannot install its stale update resource", async () => {
|
||||
const f = fixture();
|
||||
await f.controller.check();
|
||||
const next = deferred();
|
||||
f.setCheckOperation(next);
|
||||
const check = f.controller.check({ force: true });
|
||||
assert.equal(await f.controller.downloadAndInstall(), "checking");
|
||||
assert.equal(f.counts.installs, 0);
|
||||
next.resolve(null);
|
||||
await check;
|
||||
assert.equal(f.counts.closes, 1);
|
||||
});
|
||||
+47
@@ -199,6 +199,49 @@ invalid context sizes disable Save. Reopening a saved item retains its mappings;
|
||||
enabling it shows the restart notice. Actual catalog files, outbound model IDs,
|
||||
authentication and rollback are covered by the Rust and native Codex checks.
|
||||
|
||||
## Provider card model names and drag ordering (#61 / #62)
|
||||
|
||||
1. Reset the fixture. The initial **Fixture CC Switch** model is
|
||||
`fixture-model-a`, **Fixture Saved Provider** uses `fixture-model-b`, and the
|
||||
default official profile uses `fixture-official-model`. The provider cards
|
||||
should show these model names, including the current detected provider.
|
||||
2. Copy a saved provider twice and copy an official profile once to create
|
||||
several reorderable rows. Copies keep their selected model; editing a copy's
|
||||
selected model must update its own card when returning to the list.
|
||||
3. Drag the card handles up and down, including moving the default official
|
||||
profile. Check the new visual order, leave the provider page, return, and
|
||||
reload. The order must survive. Enabling another row must keep the order and
|
||||
move only the current-provider indicator. Copying or deleting a row must keep
|
||||
the relative order of the remaining rows.
|
||||
4. Set **Fixture:排序保存** to **保存失败** and drag a card. The list should
|
||||
return to its previous saved order and show an error. Diagnostics must keep
|
||||
the original `providerOrders` value and no provider switch may occur.
|
||||
5. Select **延迟成功**, drag a card, and inspect `pendingProviderOrder: 1`.
|
||||
Use **Fixture:完成排序保存** to settle it. Select **延迟失败** and repeat:
|
||||
settling must reject the request and preserve the original saved order.
|
||||
**Fixture:使排序保存失败** rejects any pending order save explicitly.
|
||||
Changing the selector while a request is pending does not change its outcome.
|
||||
6. Check the handles and model labels in both themes, with long provider/model
|
||||
names and keyboard focus. When drag ordering is unavailable during another
|
||||
provider action, the controls should communicate that state.
|
||||
|
||||
The fixture implements `get_provider_order({ configDir }) -> string[]` and
|
||||
`save_provider_order({ configDir, order }) -> string[]`. Keys use
|
||||
`official:<id>`, `local:<id>`, and `detected:<id>`. Duplicate or malformed keys
|
||||
reject without mutation; valid stale keys are kept for the renderer to ignore.
|
||||
Ordering is stored by the normalized virtual config directory and persisted in
|
||||
`codexx.fixture.backend.v2` alongside providers. It does not reorder the backing
|
||||
provider arrays or affect failover priority. The virtual scope normalizer handles
|
||||
simple slash paths, `.` and `..`; filesystem aliases, migration, and atomic SQL
|
||||
writes are covered by the Rust tests. Other fixture data still models one shared
|
||||
configuration directory.
|
||||
|
||||
Browser assertions may use `window.__CODEX_X_FIXTURE__.setProviderOrderMode(mode)`,
|
||||
`.completeProviderOrder()`, `.failProviderOrder()`, and
|
||||
`.getProviderOrder(configDir)`. Diagnostics expose `providerOrders`,
|
||||
`providerOrderMode`, and `pendingProviderOrder`. Modes reset to `normal` on reload;
|
||||
saved order persists. Restart the fixture server after changing `ui-runtime.js`.
|
||||
|
||||
## Provider presets and retained usage results
|
||||
|
||||
The add-provider form exposes eight preset cards. Cards show only brand marks and names; switching among all presets, including official sign-in, keeps the same add-page transition key. The initial custom TOML includes a reasoning setting, a fixture MCP server and a project entry for inheritance checks. Selecting a vendor fills its
|
||||
@@ -251,3 +294,7 @@ limitation applies to official-profile capture, independence, and storage: these
|
||||
responses are models for frontend verification. Use the Rust regression tests
|
||||
for backend guarantees. All persisted fixture credentials are synthetic and
|
||||
scoped to the local fixture origin and browser session.
|
||||
|
||||
### 在线更新流程
|
||||
|
||||
在 fixture 地址后加 `?updater=1`,通过侧栏更新按钮打开模拟更新窗口。点击“立即更新”后可使用控制面板依次触发验证、准备退出、启动安装及安装交接,也可模拟准备失败和更新完成。所有文件、安装器和重启操作均为内存模拟,不会下载或安装软件。暂停操作 30 秒可检查等待提示;失败时应允许重试,安装交接后不应再次启动安装。
|
||||
|
||||
+253
-7
@@ -9,8 +9,10 @@
|
||||
const counts = {};
|
||||
const switches = [];
|
||||
const commands = [];
|
||||
const pending = { sync: [], detail: [], quota: [], resetCredits: [], usage: [] };
|
||||
const pending = { sync: [], detail: [], quota: [], resetCredits: [], usage: [], providerOrder: [] };
|
||||
const providerOrders = new Map();
|
||||
const callbacks = new Map();
|
||||
const eventListeners = new Map();
|
||||
let nextCallback = 1;
|
||||
let output;
|
||||
let loginButton;
|
||||
@@ -26,10 +28,37 @@
|
||||
let transferMode = "success";
|
||||
let lastTransfer = null;
|
||||
let existingImportAvailable = true;
|
||||
const defaultRoutingSettings = () => ({ version: 2, routerEnabled: false, takeoverEnabled: false, autoFailoverEnabled: false,
|
||||
listenAddress: "127.0.0.1", listenPort: 15721, providerIds: [], maxRetries: 3, streamingFirstByteTimeout: 60,
|
||||
streamingIdleTimeout: 120, nonStreamingTimeout: 600, circuitFailureThreshold: 4, circuitSuccessThreshold: 2,
|
||||
circuitTimeoutSeconds: 60, circuitErrorRateThreshold: 0.6, circuitMinRequests: 10 });
|
||||
let failoverSettings = defaultRoutingSettings();
|
||||
const resetRoutingHealth = new Set();
|
||||
let failoverMode = "normal";
|
||||
let providerBaseReadFailure = false;
|
||||
let providerOrderMode = "normal";
|
||||
const updaterEnabled = new URLSearchParams(location.search).has("updater");
|
||||
let updateRequest = null;
|
||||
function updateStage(event) { updateRequest?.onEvent.onmessage(event); }
|
||||
function finishUpdate(failure = false) {
|
||||
if (!updateRequest) return;
|
||||
const request = updateRequest;
|
||||
updateRequest = null;
|
||||
if (failure) request.reject({ stage: "prepare", message: "Fixture:无法恢复连接配置,尚未启动安装程序。", logPath: "C:\\Users\\Fixture\\AppData\\Local\\Codex-X\\logs\\update.log" });
|
||||
else request.resolve({ restartRequired: true });
|
||||
render();
|
||||
}
|
||||
const sharedProviderTables = '\n[mcp_servers.fixture_docs]\ncommand = "fixture-docs-server"\n'
|
||||
+ '\n[mcp_servers.fixture_docs.env]\nFIXTURE_TIMEOUT = "1000"\n'
|
||||
+ '\n[desktop]\nsansFontSize = 14\ncodeFontSize = 13\n'
|
||||
+ '\n[marketplaces.fixture]\nsource_type = "local"\nsource = "/fixture/marketplace"\n'
|
||||
+ '\n[plugins."browser@fixture"]\nenabled = true\n'
|
||||
+ '\n[projects."/fixture/project"]\ntrust_level = "trusted"\n';
|
||||
const fixtureSkill = (id, name, enabled = true) => ({ id, name, description: "Fixture skill", note: "合成测试备注", directory: id, enabled, source: "Codex", path: `${codexDir}/skills/${id}`, contentHash: null, updateStatus: "未检查" });
|
||||
const fixtureMcp = (id, name, enabled = true) => ({ id, name, enabled, transport: "stdio", source: "Codex", note: "合成测试备注", summary: "npx fixture-tools", command: "npx", url: null, configJson: { command: "npx", args: ["fixture-tools"] } });
|
||||
const transferState = { codexDir, codexSkillsDir: `${codexDir}/skills`, disabledSkillsDir: `${codexDir}/disabled-skills`, skills: [fixtureSkill("writing", "写作助手")], mcpServers: [fixtureMcp("docs", "项目文档")], warnings: [] };
|
||||
const fixtureSessions = ["项目排错记录", "功能设计讨论"].map((title, index) => ({ id: `fixture-session-${index + 1}`, title, modelProvider: "custom", model: "fixture-model", cwd: "/fixture/project", rolloutPath: `${codexDir}/sessions/fixture-${index}.jsonl`, updatedAtMs: Date.now() - index * 60000, archived: false, hasUserEvent: true, isSubagent: false, needsSync: false }));
|
||||
fixtureSessions.push({ id: "fixture-internal-guardian", title: "The following is the Codex agent history whose request action you are assessing...", modelProvider: "openai", model: "codex-auto-review", cwd: "/fixture/project", rolloutPath: `${codexDir}/sessions/guardian.jsonl`, updatedAtMs: Date.now() - 120000, archived: false, hasUserEvent: false, isSubagent: true, needsSync: false });
|
||||
|
||||
function healthReport() {
|
||||
const broken = configHealthMode !== "healthy";
|
||||
@@ -69,7 +98,7 @@
|
||||
providerName: "OpenAI Official",
|
||||
model: "fixture-official-model",
|
||||
authJson: officialAuth("one"),
|
||||
configText: officialToml("fixture-official-model"),
|
||||
configText: officialToml("fixture-official-model") + sharedProviderTables,
|
||||
isDefault: true,
|
||||
}]]);
|
||||
|
||||
@@ -134,7 +163,7 @@
|
||||
// Visible inherited settings for provider-preset regression checks.
|
||||
detectedProvider.tomlConfig = '# fixture inherited settings\nmodel_reasoning_effort = "high"\napproval_policy = "on-request"\n'
|
||||
+ toml(detectedProvider)
|
||||
+ '\n[mcp_servers.fixture_docs]\ncommand = "fixture-docs-server"\n\n[features]\nshell_snapshot = true\n\n[projects."/fixture/project"]\ntrust_level = "trusted"\n';
|
||||
+ sharedProviderTables + '\n[features]\nshell_snapshot = true\n';
|
||||
let savedProviders = [{
|
||||
id: "fixture-saved",
|
||||
providerName: "Fixture Saved Provider",
|
||||
@@ -203,7 +232,13 @@
|
||||
&& Array.isArray(saved.officialProfiles)
|
||||
&& saved.officialProfiles.some((profile) => profile.id === defaultOfficialId)) {
|
||||
state = saved.state;
|
||||
if (saved.failoverSettings?.version === 2) failoverSettings = { ...defaultRoutingSettings(), ...saved.failoverSettings };
|
||||
savedProviders = saved.savedProviders;
|
||||
for (const [scope, order] of Object.entries(saved.providerOrders || {})) {
|
||||
if (Array.isArray(order) && order.every((key) => typeof key === "string")) {
|
||||
providerOrders.set(scope, clone(order));
|
||||
}
|
||||
}
|
||||
savedPrompts = saved.savedPrompts;
|
||||
nextOfficialId = saved.nextOfficialId;
|
||||
officialProfiles.clear();
|
||||
@@ -219,8 +254,10 @@
|
||||
version: 2,
|
||||
state,
|
||||
savedProviders,
|
||||
providerOrders: Object.fromEntries(providerOrders),
|
||||
savedPrompts,
|
||||
nextOfficialId,
|
||||
failoverSettings,
|
||||
officialProfiles: Array.from(officialProfiles.values()),
|
||||
}));
|
||||
} catch {
|
||||
@@ -241,6 +278,7 @@
|
||||
return {
|
||||
startupWizardSeen: localStorage.getItem("codexx.startupWizardSeen"),
|
||||
transferMode, lastTransfer,
|
||||
failoverSettings: clone(failoverSettings), failoverMode,
|
||||
pendingSync: pending.sync.length,
|
||||
pendingDetail: pending.detail.length,
|
||||
pendingQuota: pending.quota.length,
|
||||
@@ -260,6 +298,9 @@
|
||||
usageMode,
|
||||
pendingUsage: pending.usage.length,
|
||||
savedProviderIds: savedProviders.map((provider) => provider.id),
|
||||
providerOrders: Object.fromEntries(providerOrders),
|
||||
providerOrderMode,
|
||||
pendingProviderOrder: pending.providerOrder.length,
|
||||
commands: clone(commands),
|
||||
};
|
||||
}
|
||||
@@ -345,6 +386,52 @@
|
||||
return next;
|
||||
}
|
||||
|
||||
function providerOrderScope(configDir) {
|
||||
// Virtual fixture paths have no filesystem or symlinks. Normalize simple
|
||||
// absolute paths only; Rust tests cover real canonical paths and aliases.
|
||||
const raw = typeof configDir === "string" && configDir.trim() ? configDir.trim() : codexDir;
|
||||
const parts = [];
|
||||
for (const part of raw.replaceAll("\\", "/").split("/")) {
|
||||
if (!part || part === ".") continue;
|
||||
if (part === "..") parts.pop();
|
||||
else parts.push(part);
|
||||
}
|
||||
return `/${parts.join("/")}`;
|
||||
}
|
||||
|
||||
function getProviderOrder(configDir) {
|
||||
return clone(providerOrders.get(providerOrderScope(configDir)) || []);
|
||||
}
|
||||
|
||||
function setProviderOrderMode(mode) {
|
||||
if (!["normal", "error", "pending", "pending-error"].includes(mode)) throw new Error("Unknown fixture provider-order mode");
|
||||
providerOrderMode = mode;
|
||||
render();
|
||||
}
|
||||
|
||||
function saveProviderOrder({ configDir, order }) {
|
||||
if (!Array.isArray(order) || order.some((key) => {
|
||||
if (typeof key !== "string" || /[\u0000-\u001f\u007f-\u009f]/.test(key)) return true;
|
||||
const separator = key.indexOf(":");
|
||||
const source = key.slice(0, separator);
|
||||
const id = key.slice(separator + 1);
|
||||
return !["official", "local", "detected"].includes(source) || !id || id !== id.trim();
|
||||
}) || new Set(order).size !== order.length) throw new Error("Fixture:排序包含无效或重复供应商");
|
||||
const scope = providerOrderScope(configDir);
|
||||
const next = clone(order);
|
||||
const mode = providerOrderMode;
|
||||
const failure = "Fixture:供应商排序保存失败,原顺序未改变";
|
||||
if (mode === "error") throw new Error(failure);
|
||||
const save = () => {
|
||||
providerOrders.set(scope, next);
|
||||
return next;
|
||||
};
|
||||
if (mode === "pending" || mode === "pending-error") {
|
||||
return defer("providerOrder", save, mode === "pending-error" ? failure : null);
|
||||
}
|
||||
return clone(save());
|
||||
}
|
||||
|
||||
function uniqueCopyName(requested, names) {
|
||||
const base = requested?.trim() || "Fixture 副本";
|
||||
const used = new Set(names);
|
||||
@@ -693,6 +780,50 @@
|
||||
});
|
||||
}
|
||||
|
||||
function emitRoutingChanged() {
|
||||
for (const [eventId, listener] of eventListeners) {
|
||||
if (listener.event === "provider-routing-changed") callbacks.get(listener.handler)?.({ event: listener.event, id: eventId, payload: { codexDir } });
|
||||
}
|
||||
}
|
||||
|
||||
function failoverStatus() {
|
||||
const summary = (provider) => ({ id: provider.id, providerName: provider.providerName, model: provider.model,
|
||||
baseUrl: provider.baseUrl, official: false, models: [...new Set([provider.model, ...(provider.modelMappings || []).map((mapping) => mapping.model)])] });
|
||||
const providers = savedProviders.map((provider) => {
|
||||
const reason = provider.wireApi !== "responses" ? "此供应商暂不支持 Responses 接口" : !provider.apiKey ? "请先填写 API Key" : null;
|
||||
return { ...summary(provider), eligible: !reason, reason };
|
||||
});
|
||||
const profile = state.isOfficialProvider ? officialProfiles.get(state.activeOfficialProfileId || defaultOfficialId) : null;
|
||||
const primary = profile ? { id: `official:${profile.id}`, providerName: profile.providerName, model: profile.model, models: [profile.model],
|
||||
baseUrl: "https://chatgpt.com/codex", official: true, eligible: Boolean(profile.authJson), reason: profile.authJson ? null : "请先登录官方账号" }
|
||||
: providers.find((provider) => provider.id === state.activeSavedProviderId) || null;
|
||||
const running = failoverSettings.routerEnabled;
|
||||
const takeoverActive = Boolean(running && failoverSettings.takeoverEnabled && primary?.eligible);
|
||||
const autoFailoverActive = Boolean(takeoverActive && failoverSettings.autoFailoverEnabled && !primary.official);
|
||||
const routes = !takeoverActive ? [] : autoFailoverActive ? failoverSettings.providerIds.map((id) => providers.find((provider) => provider.id === id)).filter(Boolean) : [primary];
|
||||
const samples = running && ["cooling", "recovering"].includes(failoverMode);
|
||||
const host = failoverSettings.listenAddress === "0.0.0.0" ? "127.0.0.1" : failoverSettings.listenAddress === "::" ? "[::1]" : failoverSettings.listenAddress.includes(":") ? `[${failoverSettings.listenAddress}]` : failoverSettings.listenAddress;
|
||||
return clone({ settings: failoverSettings, running, takeoverActive, autoFailoverActive, address: running ? `http://${host}:${failoverSettings.listenPort}/v1` : null, primary, providers,
|
||||
runtime: { requestCount: samples ? 28 : 0, failoverCount: samples ? 2 : 0, inFlight: 0, successCount: samples ? 26 : 0, failureCount: samples ? 2 : 0,
|
||||
uptimeSeconds: running ? 365 : 0, lastRequestAt: samples ? new Date().toISOString() : null,
|
||||
lastProviderId: samples ? routes.at(-1)?.id || null : null, lastError: null,
|
||||
providers: routes.map((provider, index) => { const state = resetRoutingHealth.has(provider.id) || !samples ? "closed" : index === 0 ? (failoverMode === "recovering" ? "half_open" : "open") : "closed";
|
||||
return { id: provider.id, state, cooldownSeconds: state === "open" ? 25 : 0, lastStatus: state === "closed" ? 200 : 503,
|
||||
consecutiveFailures: state === "open" ? 4 : 0, consecutiveSuccesses: state === "half_open" ? 1 : 0, totalRequests: samples ? 14 : 0, failedRequests: state === "closed" ? 0 : 4 }; }) },
|
||||
message: primary?.official && failoverSettings.autoFailoverEnabled ? "当前为官方登录,仅使用当前账号;自动故障转移暂不运行。" : autoFailoverActive && !routes.length ? "队列为空,请添加 API 供应商。" : null });
|
||||
}
|
||||
|
||||
function seedFailover() {
|
||||
for (const [id, providerName, model] of [["fixture-failover-primary", "主供应商", "gpt-example"], ["fixture-failover-backup-a", "备用一", "gpt-example"], ["fixture-failover-backup-b", "备用二", "gpt-example"], ["fixture-failover-other", "不同模型供应商", "other-model"]]) {
|
||||
saveProvider({ id, providerName, model, apiKey: "sk-fixture-only-failover", baseUrl: `https://${id}.example.test/v1`, wireApi: "responses", requiresOpenaiAuth: false, tomlConfig: "", modelMappings: [] });
|
||||
}
|
||||
switchProvider(savedProviders.find((provider) => provider.id === "fixture-failover-primary"));
|
||||
failoverMode = "normal";
|
||||
failoverSettings = defaultRoutingSettings();
|
||||
resetRoutingHealth.clear();
|
||||
persist(); render(); emitRoutingChanged();
|
||||
}
|
||||
|
||||
async function invoke(command, args = {}) {
|
||||
counts[command] = (counts[command] || 0) + 1;
|
||||
commands.push(command);
|
||||
@@ -707,6 +838,54 @@
|
||||
|
||||
async function dispatch(command, args) {
|
||||
switch (command) {
|
||||
case "get_provider_config_base": {
|
||||
if (providerBaseReadFailure) throw new Error("Fixture:无法读取供应商配置底稿");
|
||||
// Model a legacy route-only file with the official integration snapshot
|
||||
// still available. Rust tests cover the actual parser/recovery rules.
|
||||
const config = state.configText.includes("[mcp_servers")
|
||||
? state.configText : state.configText + sharedProviderTables;
|
||||
return config.replace(/^\s*experimental_bearer_token\s*=.*\n?/gm, "");
|
||||
}
|
||||
case "get_provider_failover":
|
||||
if (failoverMode === "error") throw new Error("Fixture:暂时无法读取运行状态");
|
||||
return failoverStatus();
|
||||
case "save_provider_failover": {
|
||||
if (failoverMode === "save-error") throw new Error("Fixture:设置保存失败,原设置未改变");
|
||||
const next = clone(args.settings);
|
||||
const status = failoverStatus();
|
||||
const ranges = { maxRetries: [0, 10], streamingFirstByteTimeout: [1, 120], streamingIdleTimeout: [0, 600], nonStreamingTimeout: [60, 1200], circuitFailureThreshold: [1, 20], circuitSuccessThreshold: [1, 10], circuitTimeoutSeconds: [0, 300], circuitErrorRateThreshold: [0, 1], circuitMinRequests: [5, 100] };
|
||||
for (const [key, [min, max]] of Object.entries(ranges)) if (!Number.isFinite(next[key]) || next[key] < min || next[key] > max || key !== "circuitErrorRateThreshold" && !Number.isInteger(next[key])) throw new Error(`Fixture:参数 ${key} 超出范围`);
|
||||
if (!Number.isInteger(next.listenPort) || next.listenPort < 1024 || next.listenPort > 65535) throw new Error("Fixture:监听端口无效");
|
||||
const host = next.listenAddress.trim();
|
||||
let validAddress = /^(\d{1,3}\.){3}\d{1,3}$/.test(host) && host.split(".").every((part) => Number(part) <= 255 && String(Number(part)) === part);
|
||||
if (host.includes(":")) { try { validAddress = new URL(`http://[${host}]/`).hostname.startsWith("["); } catch {} }
|
||||
if (host === "localhost") validAddress = true;
|
||||
if (!validAddress) throw new Error("Fixture:监听地址无效");
|
||||
next.listenAddress = host === "localhost" ? "127.0.0.1" : host;
|
||||
if (status.running && (next.listenAddress !== failoverSettings.listenAddress || next.listenPort !== failoverSettings.listenPort)) throw new Error("Fixture:停止路由后再修改监听地址");
|
||||
if (!next.routerEnabled) next.takeoverEnabled = false;
|
||||
if (next.takeoverEnabled && !status.primary?.eligible) throw new Error("Fixture:请先选择可用供应商或登录官方账号");
|
||||
if (next.providerIds.length > 64 || new Set(next.providerIds).size !== next.providerIds.length || next.providerIds.some((id) => !status.providers.find((provider) => provider.id === id)?.eligible)) throw new Error("Fixture:队列包含无效或重复供应商");
|
||||
const enablingAuto = next.autoFailoverEnabled && !failoverSettings.autoFailoverEnabled;
|
||||
if (enablingAuto && (!next.routerEnabled || !next.takeoverEnabled)) throw new Error("Fixture:请先开启本地路由和 Codex 路由");
|
||||
if (enablingAuto && !next.providerIds.length) {
|
||||
if (!status.primary?.eligible || status.primary.official) throw new Error("Fixture:请添加 API 供应商到队列");
|
||||
next.providerIds.push(status.primary.id);
|
||||
}
|
||||
if (enablingAuto) switchProvider(savedProviders.find((provider) => provider.id === next.providerIds[0]));
|
||||
failoverSettings = next;
|
||||
emitRoutingChanged();
|
||||
return failoverStatus();
|
||||
}
|
||||
case "reset_provider_failover_health":
|
||||
if (args.providerId) resetRoutingHealth.add(args.providerId); else { failoverMode = "normal"; resetRoutingHealth.clear(); }
|
||||
return failoverStatus();
|
||||
case "plugin:event|listen": {
|
||||
const eventId = nextCallback++;
|
||||
eventListeners.set(eventId, { event: args.event, handler: args.handler });
|
||||
return eventId;
|
||||
}
|
||||
case "plugin:event|unlisten": eventListeners.delete(args.eventId); return null;
|
||||
case "check_codex_config": return healthReport();
|
||||
case "repair_codex_config": {
|
||||
if (args.expectedFingerprint !== healthReport().fingerprint) throw new Error("配置已变更,请重新检查。");
|
||||
@@ -721,6 +900,9 @@
|
||||
if (configStateError) throw new Error("Fixture:Codex 无法加载配置,Model provider my_codex not found。");
|
||||
return clone(state);
|
||||
case "list_saved_providers": return clone(savedProviders);
|
||||
case "get_provider_order": return getProviderOrder(args.configDir);
|
||||
case "save_provider_order": return saveProviderOrder(args);
|
||||
case "repair_provider_model_catalog": return { changed: false };
|
||||
case "list_saved_prompts": return clone(savedPrompts);
|
||||
case "get_builtin_prompt_status": return clone(statuses);
|
||||
case "list_official_profiles":
|
||||
@@ -745,7 +927,7 @@
|
||||
case "get_about_info": return {
|
||||
appVersion: "0.3.15", codexVersion: "fixture-cli", codexDir,
|
||||
projectUrl: "https://project.example.test/", githubRepo: "fixture/example",
|
||||
nativeUpdaterSupported: false,
|
||||
nativeUpdaterSupported: updaterEnabled,
|
||||
};
|
||||
case "get_startup_diagnostics": {
|
||||
if (new URLSearchParams(location.search).has("environmentError")) throw new Error("Fixture: environment check unavailable");
|
||||
@@ -762,7 +944,18 @@
|
||||
case "check_app_update": return {
|
||||
latestVersion: "0.3.15", htmlUrl: "https://releases.example.test/", hasUpdate: false,
|
||||
};
|
||||
case "plugin:updater|check": return null;
|
||||
case "plugin:updater|check": return updaterEnabled ? {
|
||||
rid: 123, currentVersion: "0.3.20", version: "0.3.21", date: "2026-09-22T00:00:00Z", body: "优化 Windows 在线更新。", rawJson: {},
|
||||
} : null;
|
||||
case "install_app_update": return new Promise((resolve, reject) => {
|
||||
if (updateRequest) { reject({ stage: "install", message: "Fixture:更新已经进行中。" }); return; }
|
||||
updateRequest = { resolve, reject, onEvent: args.onEvent };
|
||||
updateStage({ event: "Started", data: { contentLength: 1024 * 1024 * 20 } });
|
||||
updateStage({ event: "Progress", data: { chunkLength: 1024 * 1024 * 10 } });
|
||||
render();
|
||||
});
|
||||
case "plugin:resources|close": return null;
|
||||
case "plugin:process|restart": return null;
|
||||
case "refresh_builtin_prompts": return defer("sync", () => statuses);
|
||||
case "get_builtin_prompt_detail": {
|
||||
if (!details.has(args.templateId)) throw new Error("Unknown fixture template");
|
||||
@@ -846,8 +1039,8 @@
|
||||
case "fetch_provider_models": return { models: [{ id: "fixture-model-a" }, { id: "fixture-model-b" }], status: 200, durationMs: 1 };
|
||||
case "get_session_sync_status": return {
|
||||
codexDir, targetProvider: state.modelProvider, rolloutFiles: 2, sessionMetaCount: 2,
|
||||
mismatchedRollouts: 0, mismatchedSessionMeta: 0, sqliteDbs: 1, sqliteThreads: 2,
|
||||
topLevelThreads: 2, subagentThreads: 0, mismatchedThreads: 0, mismatchedSessions: 0,
|
||||
mismatchedRollouts: 0, mismatchedSessionMeta: 0, sqliteDbs: 1, sqliteThreads: 3,
|
||||
topLevelThreads: 2, subagentThreads: 1, mismatchedThreads: 0, mismatchedSessions: 0,
|
||||
needsSync: false, scanComplete: true, scanFailures: [], warnings: [], sessions: clone(fixtureSessions),
|
||||
};
|
||||
case "get_skills_mcp_state": return clone(transferState);
|
||||
@@ -878,6 +1071,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener(event, eventId) { eventListeners.delete(eventId); } };
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke,
|
||||
transformCallback(callback, once = false) {
|
||||
@@ -896,6 +1090,10 @@
|
||||
loginSecondOfficialAccount,
|
||||
setUsageMode,
|
||||
setQuotaMode,
|
||||
setProviderOrderMode,
|
||||
getProviderOrder,
|
||||
completeProviderOrder: () => settle("providerOrder", false),
|
||||
failProviderOrder: () => settle("providerOrder", true),
|
||||
completeQuota: () => settle("quota", false),
|
||||
failQuota: () => settle("quota", true),
|
||||
completeSync: () => settle("sync", false),
|
||||
@@ -920,6 +1118,19 @@
|
||||
panel.append(panelBody);
|
||||
const controls = document.createElement("div");
|
||||
controls.style.cssText = "display:flex;flex-wrap:wrap;gap:4px;margin-top:6px";
|
||||
if (updaterEnabled) {
|
||||
for (const [label, action] of [
|
||||
["Fixture:验证更新", () => updateStage({ event: "Verifying" })],
|
||||
["Fixture:准备退出", () => updateStage({ event: "Preparing" })],
|
||||
["Fixture:启动安装", () => updateStage({ event: "Installing" })],
|
||||
["Fixture:安装已交接", () => { updateStage({ event: "HandedOff" }); updateRequest?.resolve({ restartRequired: false }); updateRequest = null; render(); }],
|
||||
["Fixture:更新准备失败", () => finishUpdate(true)],
|
||||
["Fixture:更新完成待重启", () => finishUpdate(false)],
|
||||
]) {
|
||||
const button = document.createElement("button"); button.type = "button"; button.textContent = label;
|
||||
button.addEventListener("click", action); controls.append(button);
|
||||
}
|
||||
}
|
||||
const transferSelect = document.createElement("select");
|
||||
transferSelect.setAttribute("aria-label", "Fixture:文件操作");
|
||||
for (const [value, label] of [["success", "文件操作成功"], ["cancel", "取消文件选择"], ["error", "文件操作失败"]]) {
|
||||
@@ -989,6 +1200,22 @@
|
||||
resetCompleteButton.style.cssText = loginButton.style.cssText;
|
||||
resetCompleteButton.addEventListener("click", () => settle("resetCredits", false));
|
||||
controls.append(resetCompleteButton);
|
||||
const orderModeLabel = document.createElement("label");
|
||||
orderModeLabel.textContent = "Fixture:排序保存 ";
|
||||
const orderModeSelect = document.createElement("select");
|
||||
orderModeSelect.setAttribute("aria-label", "Fixture:排序保存");
|
||||
orderModeSelect.style.cssText = quotaModeSelect.style.cssText;
|
||||
for (const [value, text] of [["normal", "正常"], ["error", "保存失败"], ["pending", "延迟成功"], ["pending-error", "延迟失败"]]) {
|
||||
const option = document.createElement("option");
|
||||
option.value = value; option.textContent = text; orderModeSelect.append(option);
|
||||
}
|
||||
orderModeSelect.addEventListener("change", () => setProviderOrderMode(orderModeSelect.value));
|
||||
orderModeLabel.append(orderModeSelect); controls.append(orderModeLabel);
|
||||
for (const [text, failure] of [["Fixture:完成排序保存", false], ["Fixture:使排序保存失败", true]]) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button"; button.textContent = text; button.style.cssText = loginButton.style.cssText;
|
||||
button.addEventListener("click", () => settle("providerOrder", failure)); controls.append(button);
|
||||
}
|
||||
const pauseUsageLabel = document.createElement("label");
|
||||
const pauseUsageInput = document.createElement("input");
|
||||
pauseUsageInput.type = "checkbox";
|
||||
@@ -1020,6 +1247,25 @@
|
||||
button.addEventListener("click", () => setUsageMode(mode));
|
||||
controls.append(button);
|
||||
}
|
||||
for (const [text, action] of [
|
||||
["Fixture:精简的供应商配置", () => { state.configText = 'model_reasoning_effort = "high"\ndisable_response_storage = true\n' + toml(detectedProvider); providerBaseReadFailure = false; render(); }],
|
||||
["Fixture:供应商底稿读取失败", () => { providerBaseReadFailure = true; render(); }],
|
||||
["Fixture:供应商底稿读取正常", () => { providerBaseReadFailure = false; render(); }],
|
||||
["Fixture:准备自动切换数据", seedFailover],
|
||||
["Fixture:自动切换状态正常", () => { failoverMode = "normal"; render(); }],
|
||||
["Fixture:自动切换状态失败", () => { failoverMode = "error"; render(); }],
|
||||
["Fixture:自动切换保存失败", () => { failoverMode = "save-error"; render(); }],
|
||||
["Fixture:自动切换冷却状态", () => { failoverMode = "cooling"; resetRoutingHealth.clear(); render(); }],
|
||||
["Fixture:自动切换恢复状态", () => { failoverMode = "recovering"; resetRoutingHealth.clear(); render(); }],
|
||||
["Fixture:路由外部设置变化", () => { failoverSettings.maxRetries = failoverSettings.maxRetries === 3 ? 4 : 3; render(); emitRoutingChanged(); }],
|
||||
["Fixture:路由切换官方账号", () => { switchOfficial(defaultOfficialId); emitRoutingChanged(); }],
|
||||
["Fixture:路由切换其他模型", () => { const provider = savedProviders.find((item) => item.id === "fixture-failover-other"); if (provider) switchProvider(provider); emitRoutingChanged(); }],
|
||||
["Fixture:删除第一备用", () => { savedProviders = savedProviders.filter((provider) => provider.id !== failoverSettings.providerIds[0]); render(); }],
|
||||
]) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button"; button.textContent = text; button.style.cssText = loginButton.style.cssText;
|
||||
button.addEventListener("click", action); controls.append(button);
|
||||
}
|
||||
const resetButton = document.createElement("button");
|
||||
resetButton.type = "button";
|
||||
resetButton.textContent = "Fixture:重置测试数据";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { orderProviderRows } from "../src/providerRowOrder.ts";
|
||||
import { applyProviderRowOrder, moveProviderRow, orderProviderRows, providerRowKey } from "../src/providerRowOrder.ts";
|
||||
|
||||
const official = { id: "official", isCurrent: false };
|
||||
const detected = { id: "detected", isCurrent: true };
|
||||
@@ -25,3 +25,22 @@ test("an unresolved detected current remains visible before saved rows", () => {
|
||||
assert.deepEqual(rows.map((row) => row.id), ["official", "detected", "original", "copy"]);
|
||||
assert.deepEqual(rows.filter((row) => row.isCurrent).map((row) => row.id), ["detected"]);
|
||||
});
|
||||
|
||||
test("custom order crosses official/local boundaries and appends new cards", () => {
|
||||
const rows = [{ source: "official", id: "default" }, { source: "local", id: "one" }, { source: "local", id: "new" }];
|
||||
assert.deepEqual(applyProviderRowOrder(rows, ["local:one", "official:default"]).map(providerRowKey), ["local:one", "official:default", "local:new"]);
|
||||
});
|
||||
|
||||
test("missing and duplicate stored keys never hide or duplicate cards", () => {
|
||||
const rows = [{ source: "official", id: "same" }, { source: "local", id: "same" }];
|
||||
assert.deepEqual(applyProviderRowOrder(rows, ["detected:missing", "local:same", "local:same"]).map(providerRowKey), ["local:same", "official:same"]);
|
||||
});
|
||||
|
||||
test("moving up and down inserts at the intended edge without losing entries", () => {
|
||||
const original = ["official:default", "local:one", "local:two", "local:three"];
|
||||
assert.deepEqual(moveProviderRow(original, "local:three", "official:default", "before"), ["local:three", "official:default", "local:one", "local:two"]);
|
||||
assert.deepEqual(moveProviderRow(original, "official:default", "local:three", "after"), ["local:one", "local:two", "local:three", "official:default"]);
|
||||
assert.deepEqual(moveProviderRow(original, "local:one", "local:two", "before"), original);
|
||||
assert.deepEqual(moveProviderRow(original, "missing", "local:one", "after"), original);
|
||||
assert.deepEqual(original, ["official:default", "local:one", "local:two", "local:three"]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { parseRoutingDraft, routingDraft, routingFields, sameRoutingDraft } from "../src/routingSettings.ts";
|
||||
|
||||
const saved = () => ({ version: 2, routerEnabled: false, takeoverEnabled: false, autoFailoverEnabled: false, listenAddress: "127.0.0.1", listenPort: 15721,
|
||||
providerIds: [], maxRetries: 3, streamingFirstByteTimeout: 60, streamingIdleTimeout: 120, nonStreamingTimeout: 600,
|
||||
circuitFailureThreshold: 4, circuitSuccessThreshold: 2, circuitTimeoutSeconds: 60, circuitErrorRateThreshold: 0.6, circuitMinRequests: 10 });
|
||||
|
||||
test("saved routing settings roundtrip without changing switches, queue or percentage", () => {
|
||||
const settings = { ...saved(), routerEnabled: true, takeoverEnabled: true, autoFailoverEnabled: true, providerIds: ["p2", "p1"], circuitErrorRateThreshold: 0.625 };
|
||||
const draft = routingDraft(settings);
|
||||
assert.equal(draft.circuitErrorRateThreshold, "62.5");
|
||||
assert.deepEqual(parseRoutingDraft(draft), { settings, errors: {} });
|
||||
draft.providerIds.reverse();
|
||||
assert.deepEqual(settings.providerIds, ["p2", "p1"]);
|
||||
});
|
||||
|
||||
test("routing and saved failover preference remain independent when the service is stopped", () => {
|
||||
const settings = { ...saved(), autoFailoverEnabled: true, providerIds: ["only-one"], streamingIdleTimeout: 0 };
|
||||
assert.deepEqual(parseRoutingDraft(routingDraft(settings)).settings, settings);
|
||||
assert.ok(parseRoutingDraft(routingDraft({ ...settings, providerIds: [] })).settings);
|
||||
});
|
||||
|
||||
test("numeric fields accept their documented boundaries and reject empty, fractional or out-of-range values", () => {
|
||||
for (const field of routingFields) {
|
||||
for (const boundary of [field.min, field.max]) {
|
||||
const result = parseRoutingDraft({ ...routingDraft(saved()), [field.key]: String(boundary) });
|
||||
assert.ok(result.settings, `${field.key}: ${boundary}`);
|
||||
}
|
||||
const invalid = ["", " ", "NaN", "1e2", String(field.min - 1), String(field.max + 1)];
|
||||
if (field.key !== "circuitErrorRateThreshold") invalid.push("1.5");
|
||||
for (const value of invalid) {
|
||||
const result = parseRoutingDraft({ ...routingDraft(saved()), [field.key]: value });
|
||||
assert.equal(result.settings, null, `${field.key}: ${value}`);
|
||||
assert.ok(result.errors[field.key]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("listen settings allow IPv4, IPv6 and localhost but never coerce URLs or invalid ports", () => {
|
||||
for (const address of ["127.0.0.1", "0.0.0.0", "::1", "::", "2001:db8::1", "localhost"]) {
|
||||
const result = parseRoutingDraft({ ...routingDraft(saved()), listenAddress: address });
|
||||
assert.ok(result.settings, address);
|
||||
assert.equal(result.settings.listenAddress, address === "localhost" ? "127.0.0.1" : address);
|
||||
}
|
||||
for (const address of ["", "999.0.0.1", "127.000.0.1", "https://localhost", "::1/path", "example.com", "[::1]"]) {
|
||||
assert.ok(parseRoutingDraft({ ...routingDraft(saved()), listenAddress: address }).errors.listenAddress, address);
|
||||
}
|
||||
for (const port of ["", "12x", "1023", "65536", "15721.5"]) {
|
||||
assert.ok(parseRoutingDraft({ ...routingDraft(saved()), listenPort: port }).errors.listenPort, port);
|
||||
}
|
||||
for (const port of ["1024", "65535"]) assert.ok(parseRoutingDraft({ ...routingDraft(saved()), listenPort: port }).settings);
|
||||
});
|
||||
|
||||
test("the queue accepts one through 64 providers without an eight-provider limit and rejects duplicates", () => {
|
||||
const draft = routingDraft(saved());
|
||||
assert.ok(parseRoutingDraft({ ...draft, providerIds: ["single"] }).settings);
|
||||
assert.ok(parseRoutingDraft({ ...draft, providerIds: Array.from({ length: 64 }, (_, i) => String(i)) }).settings);
|
||||
assert.ok(parseRoutingDraft({ ...draft, providerIds: Array.from({ length: 65 }, (_, i) => String(i)) }).errors.providerIds);
|
||||
assert.ok(parseRoutingDraft({ ...draft, providerIds: ["same", "same"] }).errors.providerIds);
|
||||
});
|
||||
|
||||
test("draft comparison detects independent switch, listening, tuning and queue changes", () => {
|
||||
const original = routingDraft({ ...saved(), providerIds: ["a", "b"] });
|
||||
assert.ok(sameRoutingDraft(original, routingDraft({ ...saved(), providerIds: ["a", "b"] })));
|
||||
for (const change of [{ routerEnabled: true }, { takeoverEnabled: true }, { autoFailoverEnabled: true }, { listenAddress: "::1" }, { listenPort: "15722" }, { providerIds: ["b", "a"] }, ...routingFields.map(({ key }) => ({ [key]: original[key] + "0" }))]) {
|
||||
assert.equal(sameRoutingDraft(original, { ...original, ...change }), false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
SESSION_FOLDER_UNKNOWN_KEY,
|
||||
mergeSessionDirectoryGroups,
|
||||
pinnedSessionDirectoryKeys,
|
||||
readSessionPinPreferences,
|
||||
sessionDirectoryKey,
|
||||
sessionPinStorageKey,
|
||||
sessionsForDirectoryGroup,
|
||||
toggleFolderPins,
|
||||
} from "../src/sessionDirectoryIdentity.ts";
|
||||
|
||||
const sessionIds = (items) => items.map((item) => item.id);
|
||||
|
||||
test("case-sensitive directories have unique folder identities and accurate selection", () => {
|
||||
const upper = "/case-sensitive/Foo";
|
||||
const lower = "/case-sensitive/foo";
|
||||
const identities = { [upper]: "verified:upper", [lower]: "verified:lower" };
|
||||
const groups = mergeSessionDirectoryGroups([
|
||||
[upper, [{ id: "upper-one", cwd: upper }, { id: "upper-two", cwd: upper }]],
|
||||
[lower, [{ id: "lower-one", cwd: lower }, { id: "lower-two", cwd: lower }]],
|
||||
], identities);
|
||||
|
||||
assert.equal(groups.length, 2);
|
||||
assert.equal(new Set(groups.map((group) => group.folderKey)).size, 2, "React folder keys must be unique");
|
||||
assert.notEqual(sessionDirectoryKey(upper, identities), sessionDirectoryKey(lower, identities));
|
||||
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey(upper, identities))), ["upper-one", "upper-two"]);
|
||||
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey(lower, identities))), ["lower-one", "lower-two"]);
|
||||
});
|
||||
|
||||
test("case-distinct directories stay separate before filesystem identities are available", () => {
|
||||
const upper = "/offline/Foo";
|
||||
const lower = "/offline/foo";
|
||||
const groups = mergeSessionDirectoryGroups([
|
||||
[upper, [{ id: "upper", cwd: upper }]],
|
||||
[lower, [{ id: "lower", cwd: lower }]],
|
||||
]);
|
||||
|
||||
assert.equal(groups.length, 2);
|
||||
assert.equal(new Set(groups.map((group) => group.folderKey)).size, 2);
|
||||
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey(lower))), ["lower"]);
|
||||
assert.deepEqual(sessionsForDirectoryGroup(groups, "unknown-selection"), []);
|
||||
});
|
||||
|
||||
test("filesystem-verified aliases merge into one folder containing every spelling's sessions", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "codex-x-directory-identity-"));
|
||||
try {
|
||||
const target = join(root, "Workspace");
|
||||
const alias = join(root, "Workspace-alias");
|
||||
mkdirSync(target);
|
||||
symlinkSync(target, alias, process.platform === "win32" ? "junction" : "dir");
|
||||
const verifiedKey = (path) => {
|
||||
const metadata = statSync(path, { bigint: true });
|
||||
assert(metadata.isDirectory());
|
||||
return `verified:${metadata.dev}:${metadata.ino}:${realpathSync.native(path)}`;
|
||||
};
|
||||
const identities = { [target]: verifiedKey(target), [alias]: verifiedKey(alias) };
|
||||
assert.equal(identities[target], identities[alias], "the temporary filesystem must confirm the alias");
|
||||
|
||||
const original = Object.freeze([
|
||||
Object.freeze([target, Object.freeze([{ id: "target-one", cwd: target }, { id: "target-two", cwd: target }])]),
|
||||
Object.freeze([alias, Object.freeze([{ id: "alias-one", cwd: alias }])]),
|
||||
]);
|
||||
const groups = mergeSessionDirectoryGroups(original, identities);
|
||||
assert.equal(groups.length, 1);
|
||||
assert.equal(new Set(groups.map((group) => group.folderKey)).size, 1);
|
||||
assert.equal(groups[0].group, target, "the first folder label is retained");
|
||||
for (const spelling of [target, alias]) {
|
||||
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey(spelling, identities))), ["target-one", "target-two", "alias-one"]);
|
||||
}
|
||||
assert.equal(original[0][1].length, 2, "merging must not mutate parent session arrays");
|
||||
assert.equal(original[1][1].length, 1);
|
||||
assert.equal(sessionPinStorageKey(target, identities[target]), sessionPinStorageKey(alias, identities[alias]));
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("unresolved paths preserve case, backslashes, trailing spaces and roots", () => {
|
||||
const paths = [
|
||||
"/missing/Foo", "/missing/foo", "/missing/Foo/",
|
||||
"/missing/name", "/missing/name ", "/missing/name ",
|
||||
"/missing/with\\backslash", "/missing/with/backslash",
|
||||
"/", "//", "C:\\", "C:/", " ",
|
||||
SESSION_FOLDER_UNKNOWN_KEY,
|
||||
];
|
||||
const keys = paths.map((path) => sessionDirectoryKey(path));
|
||||
assert.equal(new Set(keys).size, paths.length);
|
||||
for (const path of paths) {
|
||||
assert.equal(sessionDirectoryKey(path), sessionDirectoryKey(path, {}));
|
||||
assert.notEqual(sessionDirectoryKey(path), SESSION_FOLDER_UNKNOWN_KEY);
|
||||
}
|
||||
for (const missing of [undefined, null, ""]) {
|
||||
assert.equal(sessionDirectoryKey(missing), SESSION_FOLDER_UNKNOWN_KEY);
|
||||
}
|
||||
assert.notEqual(sessionDirectoryKey("/"), sessionDirectoryKey(null));
|
||||
});
|
||||
|
||||
test("a nonempty raw path cannot borrow an inherited identity-table property", () => {
|
||||
const identities = Object.create({ "offline/Foo": "verified:unrelated" });
|
||||
assert.equal(sessionDirectoryKey("offline/Foo", identities), sessionDirectoryKey("offline/Foo"));
|
||||
assert.notEqual(sessionDirectoryKey("offline/Foo", identities), "verified:unrelated");
|
||||
});
|
||||
|
||||
test("directory pins remain independent for case-distinct folders", () => {
|
||||
const identities = { "/work/Foo": "verified:Foo", "/work/foo": "verified:foo" };
|
||||
const upper = sessionDirectoryKey("/work/Foo", identities);
|
||||
const lower = sessionDirectoryKey("/work/foo", identities);
|
||||
let stored = toggleFolderPins([], "/work/Foo", identities);
|
||||
assert.deepEqual(stored, ["path:/work/Foo"], "persistent pins must retain the original path");
|
||||
assert(pinnedSessionDirectoryKeys(stored, identities).has(upper));
|
||||
assert(!pinnedSessionDirectoryKeys(stored, identities).has(lower));
|
||||
stored = toggleFolderPins(stored, "/work/foo", identities);
|
||||
assert.deepEqual(stored, ["path:/work/Foo", "path:/work/foo"]);
|
||||
stored = toggleFolderPins(stored, "/work/Foo", identities);
|
||||
assert.deepEqual(stored, ["path:/work/foo"]);
|
||||
assert(!pinnedSessionDirectoryKeys(stored, identities).has(upper));
|
||||
assert(pinnedSessionDirectoryKeys(stored, identities).has(lower));
|
||||
assert.deepEqual(toggleFolderPins(stored, "/work/foo", identities), []);
|
||||
});
|
||||
|
||||
test("persistent raw-path pins survive online, offline and recreated directory identities", () => {
|
||||
const path = "/work/Project ";
|
||||
const online = { [path]: "verified:original-inode" };
|
||||
const recreated = { [path]: "verified:new-inode" };
|
||||
const stored = Object.freeze(toggleFolderPins([], path, online));
|
||||
assert.deepEqual(stored, ["path:/work/Project "]);
|
||||
for (const identities of [online, undefined, {}, recreated]) {
|
||||
assert(pinnedSessionDirectoryKeys(stored, identities).has(sessionDirectoryKey(path, identities)));
|
||||
assert.deepEqual(toggleFolderPins(stored, path, identities), []);
|
||||
}
|
||||
assert(!pinnedSessionDirectoryKeys(stored, recreated).has(online[path]));
|
||||
assert.deepEqual(stored, ["path:/work/Project "], "resolving or toggling must not mutate the saved pins");
|
||||
});
|
||||
|
||||
test("unpinning a verified alias removes every matching raw-path pin and keeps unrelated pins", () => {
|
||||
const target = "/work/Project";
|
||||
const alias = "/links/Project";
|
||||
const other = "/work/Other";
|
||||
const identities = { [target]: "verified:shared", [alias]: "verified:shared", [other]: "verified:other" };
|
||||
const stored = Object.freeze(["path:/work/Project", "path:/links/Project", "path:/work/Other"]);
|
||||
for (const spelling of [target, alias]) {
|
||||
const next = toggleFolderPins(stored, spelling, identities);
|
||||
assert.deepEqual(next, ["path:/work/Other"]);
|
||||
assert(!pinnedSessionDirectoryKeys(next, identities).has(identities[target]));
|
||||
assert(pinnedSessionDirectoryKeys(next, identities).has(identities[other]));
|
||||
}
|
||||
assert.deepEqual(toggleFolderPins([], alias, identities), ["path:/links/Project"]);
|
||||
assert.deepEqual(toggleFolderPins(["path:/work/Project"], alias, identities), []);
|
||||
assert.equal(stored.length, 3);
|
||||
});
|
||||
|
||||
test("case-distinct CODEX_HOME values have independent v2 pin storage", () => {
|
||||
const upper = "/homes/Foo";
|
||||
const lower = "/homes/foo";
|
||||
for (const identities of [undefined, { [upper]: "verified:home-upper", [lower]: "verified:home-lower" }]) {
|
||||
const upperKey = sessionPinStorageKey(upper, identities?.[upper]);
|
||||
const lowerKey = sessionPinStorageKey(lower, identities?.[lower]);
|
||||
assert.notEqual(upperKey, lowerKey);
|
||||
assert(upperKey.startsWith("codexx.sessionPins.v2:"));
|
||||
assert(lowerKey.startsWith("codexx.sessionPins.v2:"));
|
||||
const cache = new Map([
|
||||
[upperKey, JSON.stringify({ folders: ["upper-folder"], sessions: ["upper-session"] })],
|
||||
[lowerKey, JSON.stringify({ folders: ["lower-folder"], sessions: ["lower-session"] })],
|
||||
]);
|
||||
const storage = { getItem: (key) => cache.get(key) ?? null };
|
||||
assert.deepEqual(readSessionPinPreferences(upperKey, storage), { folders: ["upper-folder"], sessions: ["upper-session"] });
|
||||
assert.deepEqual(readSessionPinPreferences(lowerKey, storage), { folders: ["lower-folder"], sessions: ["lower-session"] });
|
||||
}
|
||||
});
|
||||
|
||||
test("an unverified home scope falls back to case-preserving paths without borrowing snapshot IDs", () => {
|
||||
const upper = "/homes/Foo";
|
||||
const lower = "/homes/foo";
|
||||
const snapshotIds = { [upper]: "snapshot:reused-file-id", [lower]: "snapshot:reused-file-id" };
|
||||
assert.equal(sessionDirectoryKey(upper, snapshotIds), sessionDirectoryKey(lower, snapshotIds));
|
||||
const upperKey = sessionPinStorageKey(upper);
|
||||
const lowerKey = sessionPinStorageKey(lower);
|
||||
assert.notEqual(upperKey, lowerKey, "snapshot identities alone must not merge persistent home scopes");
|
||||
assert.equal(sessionPinStorageKey(upper, null), upperKey);
|
||||
assert.equal(sessionPinStorageKey(lower, null), lowerKey);
|
||||
const cache = new Map([[upperKey, JSON.stringify({ folders: ["path:/work/Foo"], sessions: ["upper-only"] })]]);
|
||||
assert.deepEqual(readSessionPinPreferences(lowerKey, { getItem: (key) => cache.get(key) ?? null }), { folders: [], sessions: [] });
|
||||
});
|
||||
|
||||
test("reused physical home IDs with different verified birth scopes have isolated caches", () => {
|
||||
const home = "/homes/Project";
|
||||
const oldScope = "verified-home:device-1:file-42:birth-100";
|
||||
const newScope = "verified-home:device-1:file-42:birth-200";
|
||||
const oldKey = sessionPinStorageKey(home, oldScope);
|
||||
const newKey = sessionPinStorageKey(home, newScope);
|
||||
assert.notEqual(oldKey, newKey);
|
||||
const cache = new Map([[oldKey, JSON.stringify({ folders: ["path:/work/old"], sessions: ["old-generation"] })]]);
|
||||
const storage = { getItem: (key) => cache.get(key) ?? null };
|
||||
assert.deepEqual(readSessionPinPreferences(oldKey, storage), { folders: ["path:/work/old"], sessions: ["old-generation"] });
|
||||
assert.deepEqual(readSessionPinPreferences(newKey, storage), { folders: [], sessions: [] });
|
||||
});
|
||||
|
||||
test("ambiguous legacy v1 pins never populate either case's v2 preferences", () => {
|
||||
const homes = ["/homes/Foo", "/homes/foo"];
|
||||
const legacyKey = "codexx.sessionPins.v1:%2Fhomes%2Ffoo";
|
||||
const cache = new Map([[legacyKey, JSON.stringify({ folders: ["/work/foo"], sessions: ["legacy-session"] })]]);
|
||||
const reads = [];
|
||||
const storage = { getItem: (key) => { reads.push(key); return cache.get(key) ?? null; } };
|
||||
for (const home of homes) {
|
||||
const currentKey = sessionPinStorageKey(home);
|
||||
assert.notEqual(currentKey, legacyKey);
|
||||
assert.deepEqual(readSessionPinPreferences(currentKey, storage), { folders: [], sessions: [] });
|
||||
}
|
||||
assert.equal(new Set(reads).size, 2);
|
||||
assert(reads.every((key) => key.startsWith("codexx.sessionPins.v2:")));
|
||||
assert(!reads.includes(legacyKey));
|
||||
});
|
||||
|
||||
test("unavailable storage readers return empty pin preferences", () => {
|
||||
const storageKey = sessionPinStorageKey("/homes/Project");
|
||||
const denied = { getItem() { throw new Error("fixture storage unavailable"); } };
|
||||
assert.deepEqual(readSessionPinPreferences(storageKey, denied), { folders: [], sessions: [] });
|
||||
const deniedGetter = { get getItem() { throw new Error("fixture storage getter unavailable"); } };
|
||||
assert.deepEqual(readSessionPinPreferences(storageKey, deniedGetter), { folders: [], sessions: [] });
|
||||
});
|
||||
|
||||
test("missing-workspace sessions and root-directory sessions select separate folders", () => {
|
||||
const groups = mergeSessionDirectoryGroups([
|
||||
["No workspace", [{ id: "missing-one", cwd: null }, { id: "missing-two" }]],
|
||||
["/", [{ id: "root", cwd: "/" }]],
|
||||
]);
|
||||
assert.equal(new Set(groups.map((group) => group.folderKey)).size, 2);
|
||||
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, SESSION_FOLDER_UNKNOWN_KEY)), ["missing-one", "missing-two"]);
|
||||
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey("/"))), ["root"]);
|
||||
});
|
||||
|
||||
test("missing-workspace and root-directory pins stay independent and persist through identity changes", () => {
|
||||
const rootIdentity = { "/": "verified:root" };
|
||||
let stored = toggleFolderPins([], null, rootIdentity);
|
||||
assert.deepEqual(stored, [SESSION_FOLDER_UNKNOWN_KEY]);
|
||||
stored = toggleFolderPins(stored, "/", rootIdentity);
|
||||
assert.deepEqual(stored, [SESSION_FOLDER_UNKNOWN_KEY, "path:/"]);
|
||||
assert(pinnedSessionDirectoryKeys(stored, rootIdentity).has(SESSION_FOLDER_UNKNOWN_KEY));
|
||||
assert(pinnedSessionDirectoryKeys(stored, rootIdentity).has(rootIdentity["/"]));
|
||||
assert(pinnedSessionDirectoryKeys(stored).has(sessionDirectoryKey("/")));
|
||||
stored = toggleFolderPins(stored, undefined, rootIdentity);
|
||||
assert.deepEqual(stored, ["path:/"]);
|
||||
assert.deepEqual(toggleFolderPins(stored, "/", rootIdentity), []);
|
||||
});
|
||||
@@ -8,6 +8,173 @@
|
||||
|
||||
日志不得包含 Token、认证文件内容、用户数据或仅适用于某台电脑的隐私路径。
|
||||
|
||||
## 2026-09-30:图片输入、模型展示与供应商卡片排序(本地验证完成)
|
||||
|
||||
- Issues #63/#65 的图片拦截来自自定义模型目录的 `input_modalities = [text]`。
|
||||
生成目录现允许 `text` 与 `image`,保留真实模型 ID;供应商是否支持图片仍由其接口决定。
|
||||
独立修复命令只升级路径、所有权标记与摘要均验证通过的旧 Codex-X 目录,生成不可变新文件,
|
||||
备份并条件写入配置指针;认证、缓存、其他配置和旧目录保持可恢复。状态读取接口继续只读。
|
||||
- Issue #64 的新目录原本已包含 Ultra;Codex GUI 还由“模型选择器滑块中的 Ultra 选项”及多代理设置控制。
|
||||
补充客户端提示和旧能力目录迁移,不强制修改 Codex 的偏好。Codex CLI 0.159 读取目录与本地 Responses
|
||||
模拟请求验证通过:实际包含图片输入,Ultra 正常映射为 API 的 Max,turn 完成,没有真实供应商请求。
|
||||
- Issue #62 支持官方、已保存和检测卡片跨来源拖动;Pointer Capture 避免 WebView 原生拖放冲突,
|
||||
支持键盘上下移动、边缘连续滚动和列表外取消。顺序按配置目录独立保存到 SQLite v8 偏好表;
|
||||
保存期间阻止冲突刷新,失败恢复原顺序,新卡追加,缺席卡忽略。
|
||||
- Issue #61 卡片显示模型,映射名称与真实 ID 同时保留;当前第三方卡使用实时配置模型,官方卡优先使用
|
||||
官方登录监控读到的模型。新增显式 `CODEXX_CONFIG_DIR`,配合已有 `CODEXX_HOME` 隔离原生验证数据。
|
||||
- 618 项 Rust、64 项前端测试、类型检查通过;浏览器验证明暗布局、上下拖动、列表外取消、别名与 ID 展示、
|
||||
重载持久化、延迟保存及失败回滚。本地 `.app` 已构建、备份后替换安装,并通过本地签名与二进制摘要校验。
|
||||
已安装 App 的旧目录迁移和独立数据库启动通过;原生 App 验证模型别名及 ID、跨来源上下拖动、SQLite
|
||||
写入失败回滚和退出重启后的顺序保留,明暗主题显示正常。测试使用独立配置和数据库,真实配置/认证摘要一致。
|
||||
- 用户已批准将本批修改作为 v0.3.22 推送,并由既有 Actions 完成跨平台打包、验签与发布。
|
||||
- [v0.3.22 发布流水线](https://github.com/yynxxxxx/Codex-X/actions/runs/36705339645) 的四平台与最终发布验证全部成功;
|
||||
Windows 全量 Rust 及 NSIS/旧 MSI 安装迁移通过,两架构 macOS 签名、公证与 DMG 校验通过。
|
||||
公开更新清单的 11 个平台项验证完整,正式 Apple Silicon 更新包摘要、签名与公证票据核对通过,本地已换成正式 v0.3.22。
|
||||
|
||||
## 2026-09-22:Windows 更新改为用户安装并支持无重启迁移
|
||||
|
||||
- Windows 改用 NSIS 当前用户安装,旧 MSI 首次迁移只提升系统卸载子进程;准确核对旧产品身份、卸载结果和目录隔离,避免第二份安装。不因 MSI 返回 3010 一律要求重启;旧产品登记已移除、新目录安全隔离时继续使用新版,旧目录残留交给 Windows 后续清理。
|
||||
- 更新仍由 Tauri 插件下载和验签,随后恢复路由并正常退出。启动失败恢复原路由;恢复失败保留设置并明确提示。下载、验签、准备、交接阶段分别显示,长等待提示不引发重复安装。后台和安装器均保存阶段日志。
|
||||
- Windows 实测发现并修复 NSIS 的 GUID 字符串整数判空、不存在目录路径处理及日志覆盖问题;目录比较使用真实路径,保护取消后重试、子目录和 junction。测试脚本显式构造旧系统级安装,避免合成环境遗留路径干扰。
|
||||
- 本机 609 项 Rust、61 项前端、8 项清单测试通过,明暗主题与等待提示已检查。Windows 原生 599 项 Rust 和安装迁移全流程通过,覆盖实际 3010 无重启场景;配置哨兵及唯一卸载项检查通过。测试环境普通更新安装阶段约 1.5–1.6 秒,不含下载。
|
||||
- 修改位于独立 `codex/windows-updater-migration` 测试分支,尚未合并主分支或发布新版本。官网 EXE/MSI 下载兼容仅在本地准备,未推送官网源文件或部署。
|
||||
|
||||
## 2026-09-18:Windows 发布验证修正
|
||||
|
||||
- v0.3.19 草稿构建在 Windows 测试阶段发现路由重启恢复问题,未公开发布该版本。
|
||||
数据库存储的目录比较键会统一斜线和大小写,启动恢复却直接将其用作原生路径和运行实例键;
|
||||
Windows 的 verbatim 前缀及规范大小写因此与交互入口不一致。恢复时现先还原原生分隔符,
|
||||
再使用交互入口相同的路径解析,新增持久化目录与运行实例一致性的回归。
|
||||
- 流式停止测试在上游等待期间同步探测已关闭端口,Windows 的默认连接等待耗尽了测试的静默期限;
|
||||
改为有明确短期限的连接探测,仍验证停止监听不截断已经开始的响应,不放宽运行时超时策略。
|
||||
- 修正沿用新的 v0.3.20 标签重新发布,保留失败构建记录,不覆盖 v0.3.19 标签。
|
||||
|
||||
## 2026-09-18:按 CC Switch 对齐路由控制与故障转移
|
||||
|
||||
- 对照 CC Switch `06082e189d65e6d6dbadc35dacdac1ce6c79d89a` 的 UI → IPC → 数据库 → 运行时链路,
|
||||
将监听服务、Codex 请求接管、自动故障转移拆成三个独立状态;设置版本升级为 2,并迁移旧单开关与
|
||||
“主供应商之外的候补”记录。队列现在保存完整 P1 起始顺序,单项队列可用,不再要求模型 ID 相同。
|
||||
保存设置、首次开启自动模式时先启用有效 P1;空队列只在首次开启时自动补入当前已保存的第三方供应商。
|
||||
- 参数采用 Codex 对应默认值与边界:重试 3 次、响应头/流式首块分别等待 60 秒、流式静默 120 秒、
|
||||
非流式单次总期限 600 秒;熔断失败/恢复阈值为 4/2,等待 60 秒,错误率 60%、最少 10 个样本。
|
||||
前后端都校验;支持 IPv4/IPv6/localhost,端口或地址变更要求停止监听。通配监听写给本机客户端时
|
||||
使用回环地址,绑定非回环接口也保留本地认证。
|
||||
- 按上游状态机适配 Closed/Open/HalfOpen 熔断与单探测许可;参数热更新不清空未改变供应商的健康记录。
|
||||
尝试上限为重试次数加一,每家每请求至多一次,熔断跳过不占次数。已提交响应后不重放整条请求;
|
||||
含服务端状态的请求只尝试本次快照队首,不提供历史 response/session 的永久供应商绑定。
|
||||
- 官方账号支持独立原生 HTTP/SSE 接管,始终排除第三方自动队列。Codex 负责登录和 token 刷新;
|
||||
每请求验证当前选中 profile、实时 token/account,OAuth 与官方 API Key 分别固定官方上游,不从
|
||||
未选中账号借用认证。官方模型端点只提供受管本地目录或空目录,不宣称完整远程模型发现。
|
||||
- 手动切换或编辑当前供应商使用完整操作包装,暂时撤下覆盖后再恢复接管,不再直接关闭全部路由。
|
||||
自动成功选择按监听实例身份、运行 revision、令牌和配置代次验证后更新逻辑当前项;过期返回不能覆盖
|
||||
后来操作,也不能在停止重启后因 token/revision 恰好相同而污染新实例。
|
||||
路由设置、供应商更新、退出及崩溃恢复继续保留条件写入、认证写入顺序和失败回滚,恢复成功后才停监听。
|
||||
- 官方状态、额度、登录监控和快照读取解包后的逻辑配置;本地地址、认证令牌和代次头不能变成保存的
|
||||
供应商凭据。路由事件只提示前端静默重读当前目录,使用目录/请求代次检查并延后编辑期间的更新,
|
||||
不清空页面和未保存草稿。既有通用配置继承、官方/第三方认证隔离及内部会话过滤修复继续保留。
|
||||
- 本轮替代 2026-09-17 初版中的同模型限制、单开关、固定 30 秒冷却和手动切换关闭功能等约束;
|
||||
不包含 CC Switch 的全部协议转换器、其他应用代理、日志计费或 OAuth 设备码反代。
|
||||
具体对应、适用边界和上游差异见 [路由对照说明](ROUTING_CC_SWITCH_PARITY.md)。
|
||||
- 复用的熔断器与压缩处理保留 MIT 来源和版权;`THIRD_PARTY_NOTICES.md` 记录上游提交及完整许可。
|
||||
验证使用合成凭据、临时 CODEX_HOME 和本机服务,没有对用户真实供应商发送测试请求。
|
||||
- 598 项 Rust 测试、51 项前端测试、类型/格式/diff 检查通过,macOS `.app` 构建成功。
|
||||
浏览器验证三层开关、P1 排序、不同模型队列、参数范围与撤销、静默超时设为 0、72.5% 错误率、
|
||||
熔断重置/恢复状态、暗色对比,以及失败保存后的草稿保留。
|
||||
解锁后已正常退出旧版、替换并启动本地 App;新旧 ZIP 的完整性、可执行文件哈希与签名均通过校验。
|
||||
实际安装版确认路由页面、已有队列和参数可读取,独立监听能启动并停止,结束后恢复原有关闭状态。
|
||||
全程 config.toml 与 auth.json 哈希一致,没有接管真实账号或发送供应商测试请求。
|
||||
应用目录仅保留一份 Codex-X,旧版与构建副本压缩归档;尚未推送 GitHub。
|
||||
|
||||
## 2026-09-18:第三方供应商通用配置继承
|
||||
|
||||
- 已只读确认:当前 live 文件已退化为供应商/模型及少量根字段,默认官方账号的同目录可信快照仍保留
|
||||
MCP、desktop、marketplaces 等完整集成配置。因此官方新增能显示完整内容,第三方只能继承精简 live。
|
||||
另有启用漏洞:旧实现将 reasoning/disable_response_storage 等任意额外根字段视为完整配置,
|
||||
导致带这些字段的旧模板可整体覆盖当前通用设置。
|
||||
- 新增只读配置底稿接口,添加入口先读取一次,所有自定义/厂商预设共用该底稿;读取失败直接提示,
|
||||
不继续用精简模板替代。目录/请求变化使旧返回失效。旧精简模板编辑时也补入当前通用设置。
|
||||
- 当前文件仅有供应商相关字段时,可从同目录默认官方快照恢复 mcp_servers、desktop、marketplaces、
|
||||
plugins、projects 五个集成表;不复制历史登录凭据、供应商路由、模型、安全策略、计费档位或通知命令。
|
||||
正常完整 live 为通用设置的权威来源,不用历史快照覆盖它;底稿生成本身不写 live 或 auth.json。
|
||||
- 普通启用/编辑供应商基于最新通用配置,只更新供应商与模型设置;旧模板缺项不会删除 MCP 等设置,
|
||||
历史模板的过期通用值也不会覆盖现值。手工编辑当前供应商完整 TOML 有独立显式应用路径,保留用户
|
||||
增删通用设置的能力。1M 复选框不被视作手工修改通用配置。
|
||||
- 新建或换接口地址时清理上一个供应商的专属请求头、环境认证和查询参数,MCP 自己的 env 原样保留。
|
||||
- 每个 CODEX_HOME 在首次成功应用修复后的配置时记录处理标记,之后不再把用户有意清空的通用配置
|
||||
当成旧版损坏反复回补;预览和仅保存未启用的供应商不置标记,失败应用也不置标记。
|
||||
标记写入失败沿用现有 config/auth/官方快照回滚,不留下半完成的设置。
|
||||
- 往返回归另外复现了普通切回官方会重放旧配置的问题,官方切换现也保留最新通用设置,仅从目标官方
|
||||
配置读取模型、官方路由与账号约束;账号认证切换顺序和显式官方编辑语义保持原有规则。
|
||||
- 553 项后端测试、45 项前端测试、类型/格式检查与 macOS 构建通过。新增回归覆盖旧精简模板、已有
|
||||
精简 live、跨目录快照拒绝、MCP env、未知通用字段、显式删除、失败回滚、模型上下文与账号往返隔离。
|
||||
模拟界面确认自定义/千问/DeepSeek 与官方预设切换、保存后再编辑都保留集成表;读取底稿失败停留列表
|
||||
并提示错误,恢复读取后可正常新增。
|
||||
- 本地 App 已重新构建、签名/二进制校验、替换并启动;真实目录下只读打开自定义及千问新增草稿,
|
||||
确认五个集成表与 node_repl.env 都存在。config.toml、auth.json 和官方快照前后哈希一致,
|
||||
没有保存/启用测试供应商。旧 App 与构建副本已压缩归档,仅保留一份安装;尚未推送 GitHub。
|
||||
|
||||
## 2026-09-17:回退会话文件名的 ID 校验回归
|
||||
|
||||
- 上次内部类型检查增加了文件名与首条 session_meta.id 的核对,但直接取文件名末尾 UUID,
|
||||
把 thread/revert 生成的 `rollout-<timestamp>-<thread-id>_<rollout-id>.jsonl` 中 rollout ID
|
||||
当成线程 ID。实际首条元数据和活动 SQLite 都仍使用前面的稳定 thread ID,导致正常记录误报。
|
||||
已只读核对用户报告的三个文件的身份字段与索引,不读取输出正文、不改动实际日志或数据库。
|
||||
- 依据 [Codex 0.154.0 文件名定义](https://github.com/openai/codex/blob/rust-v0.154.0/codex-rs/rollout/src/rollout_file_name.rs),
|
||||
文件身份与查找匹配统一解析稳定 thread ID;支持普通与回退文件名,并保留现有压缩文件名匹配。
|
||||
rollout ID 不作为会话别名,不能据此选中另一条会话。索引权威路径、元数据精确匹配和内部类型检查保留。
|
||||
- 新增四项回归,覆盖普通/回退文件名、日志 ID 不冒充会话 ID、真正元数据不一致、内部回退记录不修改、
|
||||
从检查到事务同步成功,以及 SQLite 以错误 ID 引用文件时仍拒绝同步。
|
||||
- 544 项后端测试、格式检查和 macOS 构建通过;本地 App 已校验替换并启动,旧安装和构建副本压缩归档。
|
||||
本次未推送 GitHub,未对真实会话执行同步、删除或重命名。
|
||||
更新后只读复查实际目录,界面显示「普通会话已同步」,上述 ID 不一致及异常文件警告已消失。
|
||||
|
||||
## 2026-09-17:可选的供应商故障自动切换(#49)
|
||||
|
||||
- 按用户要求只在设置新增第三个标签「故障自动切换」,沿用标签过渡;供应商列表不增加入口。
|
||||
默认关闭,当前已启用的第三方供应商优先,最多选择 8 家备用并调整顺序。运行状态轮询保留草稿,
|
||||
切页暂停、目录变化或旧响应不能覆盖新页面;开启/关闭提示重新打开 Codex。
|
||||
- 对照 CC Switch 06082e189d65e6d6dbadc35dacdac1ce6c79d89a 的代理与熔断实现:故障必须由实际请求判断,
|
||||
不能用模型列表测速代替。新增仅监听 127.0.0.1 的 HTTP Responses 转发,接管当前 provider 的传输字段,
|
||||
不修改模型、模型目录、会话、auth.json 或全局认证;官方登录不参与候补。
|
||||
- 每次请求只尝试显式选中的候补,且其配置必须包含完全相同的模型 ID。连接/超时、认证/限流及部分
|
||||
服务端故障触发切换;普通参数错误不轮询。失败供应商冷却 30 秒,每家每次请求最多尝试一次。
|
||||
服务端会话状态绑定的请求不跨供应商重放;已发送响应后绝不重试整条请求,避免重复生成或工具调用。
|
||||
- 入站要求随机本地令牌、固定 Host,拒绝浏览器来源及任意目标转发;请求头、正文、并发与等待均有上限。
|
||||
出站独立使用每家保存的凭据,不透传本地令牌或官方账号头,不跟随重定向;不记录请求正文和密钥。
|
||||
- 应用数据库增加按 CODEX_HOME 隔离的设置与恢复记录,先备份/写恢复记录再原子覆盖传输字段。
|
||||
关闭功能、手动切换/编辑当前供应商及退出时先恢复直连再停监听;恢复失败阻止退出并保持服务可用。
|
||||
成功退出后拒绝排队的重新启用请求,避免遗留失效地址。启动按原端口恢复,无法恢复则回到直连并提示。
|
||||
- 仅还原仍属于本功能的字段,保留随后编辑的其他设置;历史恢复记录也可识别旧备份里的本地地址。
|
||||
状态、供应商识别、编辑草稿及旧 TOML 提交都使用真实上游,防止产生 localhost 供应商或保存本地令牌。
|
||||
保存/删除/导入供应商会即时刷新运行队列,已失效的候补使自动切换暂停;后台也检查外部配置变化。
|
||||
- 验证:540 项 Rust 测试和 45 项前端测试、类型/格式检查通过;其中 21 项真实本机 HTTP/SSE 测试
|
||||
覆盖故障候补、流式首块边界、凭据隔离、关闭/改队列、认证与长度限制,以及持续流超过单次读取超时。
|
||||
浏览器确认明暗主题、三个标签切换、添加/排序/保存、失败保留旧状态、冷却显示和离开标签停止轮询。
|
||||
本次仅使用合成服务与临时配置验证,没有启用用户真实供应商的自动切换,也没有向其发送测试请求。
|
||||
- 本地 macOS App 已构建、签名/二进制校验、替换并启动;旧安装与构建副本保留为校验过的 ZIP,
|
||||
系统只注册 Applications 中的一份 App。尚未推送 GitHub,也未发布 Windows 安装包。
|
||||
|
||||
## 2026-09-17:内部会话误报待同步与侧栏污染
|
||||
|
||||
- 原过滤主要识别 subagent,遗漏了 internal/guardian、memory_consolidation 及其 SQLite 表示;
|
||||
泛化的 thread_source=user 还会清除已有的内部来源或 spawn 关系。跳过 rollout 后,部分内部 ID
|
||||
仍留在数据库同步候选中,可能被补入桌面会话目录。这些类型在 Codex 0.153.4 已存在,并非只由新版引入。
|
||||
- 活动 SQLite、其权威 rollout 的首条 session_meta、spawn 关系和已有桌面目录共同提供分类依据;
|
||||
当前存储中的明确内部证据不会被默认 user 标记覆盖。旧数据库的同 ID 副本不覆盖活动记录的归属。
|
||||
不按标题、模型名、空正文或缺少用户事件猜测;普通父会话及仅有 forked_from_id 的用户分叉继续保留。
|
||||
- 检查计数、预览、rollout/SQLite 写入和目录补建均排除内部任务;事务提交前后复核 rollout 身份,
|
||||
分类发生变化时回滚。内部记录不会因同步改写 provider、工作目录或缺失标记,也不新增侧栏条目。
|
||||
列表达到上限时优先保留普通会话;“显示内部会话”仅影响展示,不会把内部记录加入同步候选。
|
||||
- 用量扫描共用内部类型识别,内部用量仍计入总量,有明确父会话时合并,不生成单独的用户会话行。
|
||||
相同 ID 的多个日志仍按原规则合并用量,但类型和子代理父关系以当前数据库引用的文件为准,
|
||||
避免旧内部副本隐藏当前用户会话;仅有首条 thread_source 标记的内部任务也按相同规则处理。
|
||||
- 回归使用临时数据库与日志,覆盖自动审核、来源冲突、真实用户分叉、旧副本、超长元数据与写入竞态。
|
||||
本次不对真实会话执行同步或清理;既有侧栏污染不会自动删除,防止误删用户历史。
|
||||
- 505 项 Rust 测试、45 项前端测试、类型及格式检查通过。模拟界面确认默认仅显示普通会话,
|
||||
开启内部会话显示后仍无待同步标记,且仅有内部差异时同步按钮不可用;尚未在 Windows 真机验证。
|
||||
- macOS App 已重新构建、签名与二进制校验、替换并启动;旧安装和构建副本经校验后压缩归档,
|
||||
仅保留一份应用注册。本地版本仍为 0.3.18,修复列在 Unreleased,尚未提交或推送 GitHub。
|
||||
|
||||
## 2026-09-17:v0.3.18 发布准备
|
||||
|
||||
- 汇总后台配置检查、统一检查入口、会话/MCP/Skills 导出导入、大日志统计和模型推理等级/DeepSeek WS 修复。
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
# Codex 路由与故障转移:CC Switch 对照说明
|
||||
|
||||
本说明记录 Codex-X 本轮路由实现与 CC Switch 的对应关系,供维护者检查行为和后续回归使用。
|
||||
入口为「设置 → 路由与故障转移」。本轮对齐 Codex 的监听、接管、优先队列、请求重试、熔断和
|
||||
原生官方账号路由;不等于移植 CC Switch 的全部应用适配器和请求转换功能。
|
||||
|
||||
参考版本固定为 CC Switch 提交
|
||||
[`06082e189d65e6d6dbadc35dacdac1ce6c79d89a`](https://github.com/farion1231/cc-switch/tree/06082e189d65e6d6dbadc35dacdac1ce6c79d89a)
|
||||
(2026-09-15)。以下描述以该版本源码及本项目实现为准,不能用另一版本的说明文案替代代码行为。
|
||||
|
||||
## 三层开关
|
||||
|
||||
三个状态分别持久化;界面应同时区分「保存的偏好」和「目前实际运行的状态」。
|
||||
|
||||
| 设置字段 | 默认值 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| `routerEnabled` | `false` | 启动本地监听服务;单独开启不会改写 Codex 配置。 |
|
||||
| `takeoverEnabled` | `false` | 将当前 Codex 供应商的传输配置指向本地监听;要求路由服务开启。 |
|
||||
| `autoFailoverEnabled` | `false` | 对第三方 API 请求使用完整优先队列;关闭时只使用当前供应商。 |
|
||||
|
||||
- 首次开启自动故障转移要求服务与接管均已开启。保存设置、让开关从关变为开时立即启用队列 P1。
|
||||
- 关闭接管会先恢复直连,监听服务可以继续运行。关闭路由总开关会恢复直连并关闭接管,
|
||||
但保留自动故障转移偏好、队列和参数。
|
||||
- 官方登录使用独立的原生官方路由,自动队列暂不参与;这不要求删除原有第三方队列。
|
||||
- 手动切换第三方供应商、编辑当前配置、切换官方账号时,完整原操作包在
|
||||
`with_provider_change` 中:暂时撤下本地配置覆盖,完成原操作,再按新的逻辑供应商恢复接管。
|
||||
不再沿用初版“手动切换就关闭全部自动切换”的行为。
|
||||
- 运行状态的 `running`、`takeoverActive`、`autoFailoverActive` 不能互相代替。
|
||||
未接管、当前为官方账号或恢复失败时,保存的自动开关与实际自动路由状态可能不同。
|
||||
|
||||
CC Switch 的对应字段位于 `GlobalProxyConfig`、`AppProxyConfig`,由
|
||||
[`commands/proxy.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/commands/proxy.rs)
|
||||
和 [`services/proxy.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/services/proxy.rs)
|
||||
实现。Codex-X 按 `CODEX_HOME` 隔离设置和运行实例;CC Switch 的设置按应用类型保存。
|
||||
此外,CC Switch 在最后一个应用取消接管时会尝试停止服务,Codex-X 保留独立监听开关的含义。
|
||||
|
||||
## 完整队列与 P1
|
||||
|
||||
`providerIds` 保存 **P1、P2、P3……整个队列**,不是“当前主供应商之外的备用列表”。
|
||||
当前供应商也可以加入;队列允许只有一家。新请求在自动模式下从 P1 开始,跳过暂时被熔断的项。
|
||||
成功使用后面的项后,逻辑当前供应商会更新,但下一次请求仍按队列优先级选择。
|
||||
|
||||
开启自动模式时,空队列只会自动加入当前已保存、可用于路由的第三方供应商。
|
||||
如果当前是官方账号或尚未保存的临时供应商,需要先选定有效 P1。P1 校验与启用失败不能留下
|
||||
“界面已经开启、实际目标却未切换”的半完成状态;Codex-X 使用配置及状态检查点进行恢复。
|
||||
|
||||
队列可在停止状态编辑。已经开启自动模式后,移除最后一个成员不会偷偷重填当前供应商;
|
||||
没有可用目标时请求返回 503。已删除或无效的供应商不再参与转发。供应商保存、删除、导入会刷新
|
||||
运行配置,后台也定期检查外部变化。
|
||||
|
||||
没有“必须配置相同模型 ID”的入队限制。路由不修改请求中的 `model`,也不会把它替换为每家
|
||||
供应商的默认模型;该模型是否可用仍取决于相应上游。参数不兼容等非重试错误会直接返回。
|
||||
|
||||
队列顺序在 Codex-X 的路由页独立保存,最多 64 项;这是资源上限。CC Switch 使用
|
||||
`providers.in_failover_queue`,并按供应商列表的 `sort_index`、ID 排序,没有同模型或至少两家的限制。
|
||||
对应源码为
|
||||
[`commands/failover.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/commands/failover.rs)
|
||||
和 [`database/dao/failover.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/database/dao/failover.rs)。
|
||||
|
||||
## 参数默认值与范围
|
||||
|
||||
配置版本为 2,IPC 使用 camelCase、平铺字段。下表是 Codex 的参数,不是 CC Switch 中 Claude
|
||||
那组不同的默认值。前端与 Rust 服务端都会校验;界面错误率使用百分数,持久化使用 0–1。
|
||||
|
||||
| 字段 | 默认值 | 可配置范围 / 含义 |
|
||||
| --- | --- | --- |
|
||||
| `listenAddress` | `127.0.0.1` | IPv4、IPv6 字面量或 `localhost`;不接受任意主机名。 |
|
||||
| `listenPort` | `15721` | 整数 1024–65535。 |
|
||||
| `providerIds` | `[]` | 有序供应商 ID,最多 64 项,不接受重复、空值或官方账号 ID。 |
|
||||
| `maxRetries` | `3` | 0–10;一次请求最多尝试 `maxRetries + 1` 个候选,每家至多一次。 |
|
||||
| `streamingFirstByteTimeout` | `60` 秒 | 1–120 秒;分别用于等待响应头及首个响应数据块。 |
|
||||
| `streamingIdleTimeout` | `120` 秒 | 0–600 秒;流式相邻数据读取间隔,0 表示不设置静默超时。 |
|
||||
| `nonStreamingTimeout` | `600` 秒 | 60–1200 秒;单次非流式上游尝试的总期限,包括接收正文。 |
|
||||
| `circuitFailureThreshold` | `4` | 1–20;达到连续失败次数即熔断。 |
|
||||
| `circuitSuccessThreshold` | `2` | 1–10;半开探测达到此成功次数后关闭熔断。 |
|
||||
| `circuitTimeoutSeconds` | `60` 秒 | 0–300 秒;从熔断到允许探测的等待时间。 |
|
||||
| `circuitErrorRateThreshold` | `0.6` | 0–1,即界面的 0–100%;达到该错误率可触发熔断。 |
|
||||
| `circuitMinRequests` | `10` | 5–100;错误率判断所需的最少样本数。 |
|
||||
|
||||
监听地址、端口只能在停止服务后修改。`localhost` 规范为 `127.0.0.1`;监听所有网卡时,
|
||||
写入本机 Codex 的连接地址使用回环地址:`0.0.0.0 → 127.0.0.1`,`:: → ::1`。
|
||||
IPv6 URL 使用方括号,实际绑定使用 `IpAddr`,不照搬上游裸 IPv6 字符串拼接的问题。
|
||||
|
||||
自动故障转移关闭或当前为官方账号时,运行路径只尝试当前目标,不使用上述熔断策略;
|
||||
响应头等待和非流式请求采用 600 秒默认期限,不设置流式首数据块与静默期限。
|
||||
本地入站读取、连接建立和向客户端写出仍有独立资源保护,不能将此理解为所有等待都无限制。
|
||||
|
||||
上游依据:
|
||||
[`database/dao/proxy.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/database/dao/proxy.rs)、
|
||||
[`proxy/types.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/proxy/types.rs)、
|
||||
[`AutoFailoverConfigPanel.tsx`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src/components/proxy/AutoFailoverConfigPanel.tsx)。
|
||||
|
||||
## 请求重试与熔断
|
||||
|
||||
一次请求持有开始时的路由、凭据和参数快照。改队列或参数影响后续请求,不把一条正在处理的请求
|
||||
换成另一套凭据。被熔断或被其他半开探测占用的候选不消耗实际尝试次数。
|
||||
|
||||
连接失败、响应头等待失败、尚未输出时的首包失败及可重试 HTTP 错误可尝试下一家。
|
||||
HTTP 400–599 中,以下状态不进入候补重试:`400/405/406/413/414/415/422/501`。
|
||||
这些请求错误不累计供应商熔断失败。3xx 不跟随重定向,也不携带凭据跳转到另一地址。
|
||||
|
||||
拿到首个流式数据块后才提交下游响应;提交响应头或开始输出后,不再重试整条请求。
|
||||
随后中断只结束当前流并更新错误提示,避免重复生成和重复工具执行。非流式正文在本次尝试的
|
||||
总期限内完整读取;不是每收到一块数据就重新开始总计时。
|
||||
|
||||
带 `previous_response_id`、`conversation`、`background=true`、输入引用、文件 ID、加密内容或
|
||||
不透明轮次状态的请求,只尝试**本次配置快照的队首**,不会失败后跨候补重放。
|
||||
实现没有保存“历史 response ID / session → 原供应商”的长期绑定,不能承诺识别每段历史状态的
|
||||
原始账户。此前一次请求在 P2 成功,并不意味着之后所有含历史状态的请求都自动绑定到 P2。
|
||||
|
||||
熔断器保留上游状态机:
|
||||
|
||||
| 状态 | 行为 |
|
||||
| --- | --- |
|
||||
| `closed` | 允许请求;连续失败达到阈值,或达到最少样本后错误率达到阈值,则转为 `open`。 |
|
||||
| `open` | 等待恢复时间;到期后允许进入 `half_open`。 |
|
||||
| `half_open` | 同时最多放行一个真实请求进行探测;失败立即重新熔断,成功达到恢复阈值后转为 `closed`。 |
|
||||
|
||||
探测由后续真实请求触发,不是定时向供应商发送额外测试消息。错误率使用当前熔断器生命周期的
|
||||
累计样本,不是按分钟滚动的时间窗口;恢复关闭或手动重置会清空相应统计。
|
||||
单项自动队列同样使用熔断器。关闭自动模式和官方路由绕过熔断器。
|
||||
|
||||
修改熔断参数会热更新已有实例,不借此清空失败记录。更换供应商的实际连接或凭据会重建对应健康
|
||||
状态;移出运行路由、停止监听后,也不能把旧内存统计当作持续存在的探测结果。
|
||||
「撤销修改」(恢复最近保存值)与「重置健康状态」是不同操作;后者不会主动验证服务已恢复。
|
||||
|
||||
上游依据:
|
||||
[`provider_router.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/proxy/provider_router.rs)、
|
||||
[`forwarder.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/proxy/forwarder.rs)、
|
||||
[`circuit_breaker.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/proxy/circuit_breaker.rs)。
|
||||
|
||||
## 原生官方账号隔离
|
||||
|
||||
官方登录可以接入本地 HTTP/SSE 路由,但不能加入第三方自动队列。切换到官方账号时,保留已保存
|
||||
的自动偏好和队列,实际只运行当前官方账号;不会拿 OAuth 请求尝试第三方候补。
|
||||
|
||||
- Codex 继续拥有登录和 token 刷新。本模块不实现设备码登录,不主动刷新 token,也不写 `auth.json`。
|
||||
- 本地官方配置使用 `requires_openai_auth=true`,关闭 WebSocket,并通过
|
||||
`x-codex-x-route-token` 验证本地调用者;不把 OAuth 替换为第三方 API Key 占位符。
|
||||
- 每次请求检查逻辑官方状态、当前选中的 profile、实时认证文件中的精确 token 和 account ID。
|
||||
同一 Team workspace 下不同用户的 token 也不能混用;不从未选中账号快照借用认证。
|
||||
- OAuth 只发往 `https://chatgpt.com/backend-api/codex`;官方 API Key 认证只发往
|
||||
`https://api.openai.com/v1`。拒绝混用两种认证,实际目的地址以请求时通过验证的认证类型为准。
|
||||
- `x-codex-x-route-token` 和内部配置代次头不会转发上游,供应商自定义头也不能重新注入它们。
|
||||
- 官方状态、邮箱/套餐摘要、额度查询、登录监控和快照捕获读取解包后的逻辑配置;不能把 localhost
|
||||
识别为新第三方供应商,也不能把本地路由令牌保存为账号凭据。
|
||||
- 官方 `GET /v1/models` 只读取当前引用且经过归属检查的本地受管模型目录,否则返回 `{"models":[]}`。
|
||||
不请求外部模型目录,也不宣称它是完整的官方模型发现接口。尚未登录时可建立接管配置,实际请求仍须登录。
|
||||
|
||||
这对应 CC Switch 的 `apply_codex_official_proxy_route`、`is_codex_official_provider` 和官方认证透传分支,
|
||||
不是 `codex_oauth_auth.rs` 中为其他客户端代管设备码登录/刷新、再转换请求的 OAuth 反代模式。
|
||||
参考
|
||||
[`codex_config.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/codex_config.rs)
|
||||
及 [`providers/codex.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/proxy/providers/codex.rs)。
|
||||
|
||||
## 配置恢复、切换与退出
|
||||
|
||||
接管先保存恢复记录,再条件原子写入本地传输字段。恢复记录包含原始供应商表、监听位置、认证
|
||||
令牌和配置代次标识,按 `CODEX_HOME` 隔离。恢复使用原始、已安装和当前值进行比较,保留随后
|
||||
编辑的其他字段,并清除仍属于本功能的地址、令牌及代次头。历史恢复记录可识别旧备份中的本地路由。
|
||||
|
||||
成功转到队列其他供应商后,后台检查运行 revision、令牌、配置归属与队列成员身份,再更新逻辑
|
||||
当前供应商和恢复记录。后台通知另外校验监听实例身份,停止后重新启动也不能将旧请求误认为新实例
|
||||
的结果。过期请求不能覆盖之后的手动选择。不会因此重写当前请求的模型名、会话数据或 MCP、desktop 等通用配置。界面只静默读取当前目录状态,编辑中的表单暂缓刷新,避免覆盖草稿。
|
||||
|
||||
启动时先处理遗留的受管配置,再按保存的监听/接管偏好恢复服务。如果供应商已在外部改变,不
|
||||
强行接管新配置;启动恢复失败时报告具体原因。退出先恢复直连,再停止监听;恢复失败
|
||||
保留可服务的实例并阻止普通退出。成功退出后拒绝排队的重新启用请求。
|
||||
|
||||
关闭窗口继续驻留托盘。停止监听不主动截断已经开始输出的流,但停止后不再发起新的上游尝试;
|
||||
退出整个进程仍不能保证未完成流继续。首次接管、关闭接管或更换账号后,已经运行的 Codex 可能缓存
|
||||
原配置;需要重新打开客户端或新建会话的情况不能通过修改文件强制消除。
|
||||
|
||||
这些恢复保护是 Codex-X 保留的实现约束。参考版本的 CC Switch 在退出时先停服务再恢复,恢复失败
|
||||
主要记录日志后继续退出;本项目没有照搬这种失败路径。
|
||||
|
||||
## 适用边界与实现差异
|
||||
|
||||
- 当前数据面提供 `POST /v1/responses`、`POST /v1/responses/compact`、`GET /v1/models`,使用 HTTP/SSE,
|
||||
不提供 WebSocket 升级或任意 URL 转发。
|
||||
- 第三方接口需兼容 Responses。没有 Chat Completions/Anthropic 协议转换、请求整流器、图片降级、
|
||||
Claude/Gemini/Grok 应用接管或跨应用配置管理;不能把这些上游能力写成本项目已具备。
|
||||
- 队列不限制模型名称,但不保证任意第三方都支持请求中的模型、工具或服务端状态。
|
||||
厂商专用 `query_params` 目前不支持自动路由;无效认证、缺失环境变量或不适用的认证头会报告原因。
|
||||
- 本地监听即使绑定非回环地址,也保留随机令牌、Host 和来源检查。不是无需认证的公开代理。
|
||||
不跟随重定向,不向第三方传递官方账号头、Cookie、本地令牌或其他账户凭据。
|
||||
- 入站头最多 32 KiB,正文及每层解压结果最多 64 MiB,查询串最多 4 KiB;使用 8 个有界工作线程。
|
||||
支持 gzip、deflate、zstd 请求解码,不记录原始请求正文或 token。
|
||||
- 运行统计是传输层统计:完整非流式响应或首个流式输出计为一次成功;之后的流中断另行报告。
|
||||
这不是对完整答案质量或整个工具调用流程成功的判断。该统计也不是订阅额度或金额统计。
|
||||
- 本轮未提供 CC Switch 的请求日志、成本计费及其他应用的开关。已有「用量统计」与官方额度查询
|
||||
仍由各自功能负责。
|
||||
|
||||
## 代码与许可来源
|
||||
|
||||
本项目对应实现为 `failover/config.rs`、`controller.rs`、`proxy.rs`、`circuit_breaker.rs`、
|
||||
`native_official.rs`,以及命令、官方配置读取和前端事件接线。
|
||||
|
||||
熔断器状态机与部分压缩处理按 CC Switch 上述提交的 MIT 代码适配;保留其版权声明
|
||||
`Copyright (c) 2025 Jason Young`。同步锁、有界本地 HTTP 处理、状态持久化、配置恢复及界面接入
|
||||
按 Codex-X 的现有结构实现。完整许可和来源见 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)。
|
||||
|
||||
验证使用临时目录、合成认证和本机 HTTP/SSE 服务,覆盖参数校验、P1 与队列、熔断、超时、
|
||||
已提交输出后的禁止重放、并发切换、恢复失败、原生官方隔离、模型目录归属和草稿保留。
|
||||
本轮 598 项 Rust 测试、51 项前端测试、类型/格式/diff 检查及 macOS `.app` 构建通过;
|
||||
浏览器检查已覆盖三层开关、队列排序、不同模型、参数编辑、熔断重置和明暗主题。
|
||||
本地 macOS App 已校验替换并启动,实际安装版验证了页面读取及监听服务独立启停,
|
||||
结束后恢复原关闭状态,现有 config.toml 与 auth.json 哈希未变;尚未验证 Windows 原生安装包。
|
||||
@@ -0,0 +1,39 @@
|
||||
# Windows 更新与旧 MSI 迁移
|
||||
|
||||
Windows 新安装包使用 NSIS EXE,安装到原登录用户的本地应用目录。后续更新直接覆盖该用户的程序文件,不再每次执行 MSI major upgrade。旧 MSI 的第一次迁移仍需要 Windows Installer 和一次管理员授权,不能承诺该次系统事务即时完成。
|
||||
|
||||
## 更新交接
|
||||
|
||||
- 插件 `check` 返回的 `Update` 资源仍负责下载与签名验证。自定义 `install_app_update` 只接受本 WebView 的资源 ID,不能指定任意安装路径或未验签字节。
|
||||
- 下载使用独立的 10 分钟总超时,不再继承版本检查的 15 秒超时。前端显示下载、验签、准备退出、启动安装、已交给安装器;30 秒无进展提供提示,但不并发重试或强杀安装器。
|
||||
- Windows 先把已验签 EXE 写入本次私有临时目录,再恢复接管配置和停止本地监听。准备失败不启动安装;启动失败恢复保存的路由,恢复失败保留偏好并如实提示。
|
||||
- 启动安装器使用原生 `Command` 参数,随后通过正常 Tauri 退出事件释放窗口、托盘和单实例锁。安装器通过 `/CODEXXPID` 句柄等待旧进程退出;30 秒未退出则停止本次安装,不杀进程、不复制新文件。
|
||||
- “已交给安装器”只表示成功启动,不等于更新已完成。后续 IPC 断开不能触发第二个安装进程。
|
||||
- macOS/Linux 继续使用插件原有安装逻辑,安装成功后才进入等待重启状态。
|
||||
|
||||
## 兼容已发布的 MSI
|
||||
|
||||
自定义模板基于 Tauri CLI 2.11.4。旧安装只按本项目固定 UpgradeCode `{1482A8E8-9217-517B-8528-93A6C90E0C2F}` 和 Windows Installer machine context 检查,不执行注册表中的任意卸载命令。
|
||||
|
||||
只提升系统 `msiexec.exe /x {ProductCode}` 子进程的权限;EXE 安装器始终留在原登录用户下,避免使用另一个管理员凭据授权后安装到管理员的个人目录。旧 MSI 保留的 Program Files 路径不作为新安装目录。
|
||||
|
||||
卸载未成功、取消授权、其他安装事务占用或结果未知时,停止复制新版本。移除返回 `0` 或 `3010` 后,再次验证旧产品注册已消失,并确认新目录与卸载前记录的旧目录隔离,才写入新安装。`3010` 表示旧文件可能需要 Windows 后续清理;在上述条件成立时继续安装,不要求用户重启电脑。
|
||||
|
||||
旧目录通过目录句柄解析到真实路径,处理 junction、短路径和驱动别名;目标不能是旧目录或其子目录。旧路径记录保留到异常退出后的重试,防止卸载记录消失后误选旧目录。不会用整机“待重启”状态阻止更新,也不查询启动时间。配置、账号、会话目录不属于迁移删除范围。
|
||||
|
||||
安装器按阶段记录日志,旧版卸载另外记录 MSI 详细日志。长时间等待会持续显示已等待时间和日志位置,不强制结束系统安装事务。
|
||||
|
||||
## 发布入口
|
||||
|
||||
更新清单的 `windows-x86_64`、`windows-x86_64-nsis`、`windows-x86_64-msi` 三个键必须指向同一个 EXE 和签名。旧 MSI 客户端下载后按实际文件内容识别安装器,因此可以完成过渡。发布校验禁止混入不同的 MSI 安装目标。
|
||||
|
||||
官网本地下载选择器已支持优先 EXE、回退旧 MSI;官网是独立部署,软件正式发布前须同步该兼容修改。便携版仍使用手动更新。
|
||||
|
||||
## 验证
|
||||
|
||||
- 本机 Rust 回归覆盖安装交接、失败恢复、端口冲突、签名失败阶段、并发更新保护和安装包暂存;前端覆盖各阶段、迟到事件、断连和长等待。
|
||||
- NSIS 模板可在本机编译,但不能据此宣称已通过 Windows 安装测试。
|
||||
- `Windows installer verification` 工作流在独立 Windows runner 构建测试安装器,运行后端测试和 `scripts/windows-install-smoke.ps1`。测试包括全新安装、真实 v0.3.20 MSI 迁移、后续更新、唯一卸载项和用户配置哨兵保留;不发布正式版本。
|
||||
- 正式 Release 同样要求这组原生安装测试通过才发布。普通用户的管理员授权、第三方安全软件和系统安装服务仍应在实际 Windows 环境验证;无法保证所有系统环境均无等待。
|
||||
|
||||
2026-09-22:代码提交 `85e08b5` 的 [Windows 原生验证](https://github.com/yynxxxxx/Codex-X/actions/runs/35748671885) 全部通过:599 项后端测试、全新用户安装、真实 v0.3.20 MSI 迁移、重复更新、拒绝旧目录/子目录/junction,以及实际 MSI 返回 3010 后无需重启继续安装。该测试运行器测得全新安装 2.5 秒、普通更新 1.5–1.6 秒、首次正常迁移 3.3 秒;这些是安装阶段耗时,不含下载,不能视为所有用户电脑的保证值。配置哨兵及唯一卸载项检查通过。macOS 主机另有 609 项后端测试、61 项前端测试及 8 项更新清单校验通过。
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codex-x-workspace",
|
||||
"private": true,
|
||||
"version": "0.3.18",
|
||||
"version": "0.3.23",
|
||||
"scripts": {
|
||||
"dev": "pnpm --dir apps/desktop tauri dev",
|
||||
"build": "pnpm --dir apps/desktop tauri build",
|
||||
|
||||
@@ -18,8 +18,9 @@ PLATFORM_ASSETS = {
|
||||
"darwin-aarch64-app": "Codex-X.app.tar.gz",
|
||||
"darwin-x86_64": "Codex-X-intel.app.tar.gz",
|
||||
"darwin-x86_64-app": "Codex-X-intel.app.tar.gz",
|
||||
"windows-x86_64": "Codex-X.msi",
|
||||
"windows-x86_64-msi": "Codex-X.msi",
|
||||
"windows-x86_64": "Codex-X.exe",
|
||||
"windows-x86_64-nsis": "Codex-X.exe",
|
||||
"windows-x86_64-msi": "Codex-X.exe",
|
||||
"linux-x86_64": "Codex-X.AppImage",
|
||||
"linux-x86_64-deb": "Codex-X.deb",
|
||||
"linux-x86_64-rpm": "Codex-X.rpm",
|
||||
@@ -131,6 +132,60 @@ class ValidateUpdaterReleaseTests(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
def test_migration_adds_legacy_msi_key_without_changing_other_platforms(self) -> None:
|
||||
manifest = json.loads(self.manifest_path.read_text())
|
||||
del manifest["platforms"]["windows-x86_64-msi"]
|
||||
original = manifest["platforms"]
|
||||
self.manifest_path.write_text(json.dumps(manifest))
|
||||
result = self.run_validator("--rewrite-download-urls", "--migrate-windows-to-nsis")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
actual = json.loads(self.manifest_path.read_text())["platforms"]
|
||||
self.assertEqual(actual["windows-x86_64-msi"], actual["windows-x86_64-nsis"])
|
||||
self.assertEqual(actual["windows-x86_64"], actual["windows-x86_64-nsis"])
|
||||
for key, value in original.items():
|
||||
if not key.startswith("windows-"):
|
||||
self.assertEqual(actual[key]["signature"], value["signature"])
|
||||
self.assertEqual(actual[key]["url"].rsplit("/", 1)[1], value["url"].rsplit("/", 1)[1])
|
||||
|
||||
def test_migration_requires_an_actual_nsis_source_entry(self) -> None:
|
||||
manifest = json.loads(self.manifest_path.read_text())
|
||||
del manifest["platforms"]["windows-x86_64-nsis"]
|
||||
self.manifest_path.write_text(json.dumps(manifest))
|
||||
result = self.run_validator("--rewrite-download-urls", "--migrate-windows-to-nsis")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("missing signed NSIS", result.stderr)
|
||||
|
||||
def test_rejects_windows_signature_divergence(self) -> None:
|
||||
manifest = json.loads(self.manifest_path.read_text())
|
||||
manifest["platforms"]["windows-x86_64-msi"]["signature"] = base64.b64encode(b"x" * 64).decode()
|
||||
self.manifest_path.write_text(json.dumps(manifest))
|
||||
result = self.run_validator("--rewrite-download-urls")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("same signed NSIS installer", result.stderr)
|
||||
|
||||
def test_rejects_mixed_msi_release_assets(self) -> None:
|
||||
assets = json.loads(self.assets_path.read_text())
|
||||
assets.append({"name": "legacy.msi", "browser_download_url": draft_url("legacy.msi")})
|
||||
self.assets_path.write_text(json.dumps(assets))
|
||||
result = self.run_validator("--rewrite-download-urls")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("must not be mixed", result.stderr)
|
||||
|
||||
def test_rejects_msi_payload_even_when_nsis_key_exists(self) -> None:
|
||||
manifest = json.loads(self.manifest_path.read_text())
|
||||
assets = json.loads(self.assets_path.read_text())
|
||||
for item in manifest["platforms"].values():
|
||||
item["url"] = item["url"].replace("Codex-X.exe", "Codex-X.msi")
|
||||
for item in assets:
|
||||
if item["name"] == "Codex-X.exe":
|
||||
item["name"] = "Codex-X.msi"
|
||||
item["browser_download_url"] = draft_url("Codex-X.msi")
|
||||
self.manifest_path.write_text(json.dumps(manifest))
|
||||
self.assets_path.write_text(json.dumps(assets))
|
||||
result = self.run_validator("--rewrite-download-urls", "--migrate-windows-to-nsis")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("expected a .exe updater", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -17,13 +17,30 @@ REQUIRED_PLATFORMS = {
|
||||
"darwin-aarch64-app": ".app.tar.gz",
|
||||
"darwin-x86_64": ".app.tar.gz",
|
||||
"darwin-x86_64-app": ".app.tar.gz",
|
||||
"windows-x86_64": ".msi",
|
||||
"windows-x86_64-msi": ".msi",
|
||||
"windows-x86_64": ".exe",
|
||||
"windows-x86_64-nsis": ".exe",
|
||||
# Older MSI clients select this key, then identify EXE/MSI by file content.
|
||||
"windows-x86_64-msi": ".exe",
|
||||
"linux-x86_64": ".AppImage",
|
||||
"linux-x86_64-deb": ".deb",
|
||||
"linux-x86_64-rpm": ".rpm",
|
||||
"linux-x86_64-appimage": ".AppImage",
|
||||
}
|
||||
WINDOWS_PLATFORMS = (
|
||||
"windows-x86_64", "windows-x86_64-nsis", "windows-x86_64-msi"
|
||||
)
|
||||
|
||||
|
||||
def migrate_windows_to_nsis(manifest: dict[str, Any]) -> None:
|
||||
"""Serve the same signed NSIS installer to old MSI and new NSIS clients."""
|
||||
platforms = manifest.get("platforms")
|
||||
if not isinstance(platforms, dict):
|
||||
fail("latest.json has no platforms object")
|
||||
source = platforms.get("windows-x86_64-nsis")
|
||||
if not isinstance(source, dict):
|
||||
fail("missing signed NSIS platform for Windows migration")
|
||||
for platform in WINDOWS_PLATFORMS:
|
||||
platforms[platform] = dict(source)
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
@@ -103,6 +120,7 @@ def main() -> None:
|
||||
parser.add_argument("--repository", required=True)
|
||||
parser.add_argument("--release-tag", required=True)
|
||||
parser.add_argument("--rewrite-download-urls", action="store_true")
|
||||
parser.add_argument("--migrate-windows-to-nsis", action="store_true")
|
||||
parser.add_argument("--require-signature-assets", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -144,6 +162,11 @@ def main() -> None:
|
||||
if not isinstance(platforms, dict):
|
||||
fail("latest.json has no platforms object")
|
||||
|
||||
if args.migrate_windows_to_nsis:
|
||||
if not args.rewrite_download_urls:
|
||||
fail("--migrate-windows-to-nsis requires --rewrite-download-urls")
|
||||
migrate_windows_to_nsis(manifest)
|
||||
|
||||
if args.rewrite_download_urls:
|
||||
rewritten = rewrite_download_urls(
|
||||
args.manifest,
|
||||
@@ -185,6 +208,15 @@ def main() -> None:
|
||||
if args.require_signature_assets and f"{asset_name}.sig" not in asset_names:
|
||||
fail(f"signature asset is missing for {asset_name}")
|
||||
|
||||
windows_entries = {
|
||||
(platforms[key]["url"], platforms[key]["signature"])
|
||||
for key in WINDOWS_PLATFORMS
|
||||
}
|
||||
if len(windows_entries) != 1:
|
||||
fail("all Windows updater keys must use the same signed NSIS installer")
|
||||
if any(name.lower().endswith((".msi", ".msi.zip")) for name in asset_names):
|
||||
fail("legacy MSI installers must not be mixed into an NSIS release")
|
||||
|
||||
signature_status = (
|
||||
"signature assets required"
|
||||
if args.require_signature_assets
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# Run only on an ephemeral GitHub Windows runner. Never run on a user's machine.
|
||||
param([Parameter(Mandatory = $true)][string]$Installer)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_OS -ne 'Windows') {
|
||||
throw 'The installation smoke test is restricted to an ephemeral Windows Actions runner.'
|
||||
}
|
||||
$Installer = (Resolve-Path $Installer).Path
|
||||
$UpgradeCode = '{1482A8E8-9217-517B-8528-93A6C90E0C2F}'
|
||||
$LegacyProductCode = '{F71E3F3F-A463-4397-AB46-206D3FAC3FBD}'
|
||||
$LegacySha256 = 'e0f2f172a31f860806a9714bab2e67eb38b85d525d7e5f0f569667cbeadfb152'
|
||||
$InstallDir = Join-Path $env:LOCALAPPDATA 'Codex-X'
|
||||
$UninstallKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Codex-X'
|
||||
$Work = Join-Path $env:RUNNER_TEMP 'codex-x-installer-smoke'
|
||||
New-Item -ItemType Directory -Force $Work | Out-Null
|
||||
Add-Type @'
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
public static class CodexXMsiSmoke {
|
||||
[DllImport("msi.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern uint MsiEnumRelatedProducts(string code, uint reserved, uint index, StringBuilder product);
|
||||
[DllImport("msi.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern uint MsiGetProductInfoEx(string code, System.IntPtr sid, uint context, string property, StringBuilder value, ref uint length);
|
||||
}
|
||||
'@
|
||||
function Related-Products {
|
||||
$results = @()
|
||||
for ($index = 0; $index -lt 64; $index++) {
|
||||
$product = [Text.StringBuilder]::new(39)
|
||||
$result = [CodexXMsiSmoke]::MsiEnumRelatedProducts($UpgradeCode, 0, $index, $product)
|
||||
if ($result -eq 259) { return $results }
|
||||
if ($result -ne 0) { throw "MsiEnumRelatedProducts failed: $result" }
|
||||
$results += $product.ToString()
|
||||
}
|
||||
throw 'Unexpected number of MSI registrations.'
|
||||
}
|
||||
function Invoke-Installer([string]$File, [string]$Arguments, [int]$ExpectedExit = 0) {
|
||||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||
$process = Start-Process -FilePath $File -ArgumentList $Arguments -PassThru
|
||||
if (-not $process.WaitForExit(600000)) {
|
||||
# Do not kill msiexec or an installer transaction. Fail the disposable job.
|
||||
throw "Installer has not finished after ten minutes (PID $($process.Id)); no forced termination was attempted."
|
||||
}
|
||||
$process.Refresh()
|
||||
if ($process.ExitCode -ne $ExpectedExit) { throw "Installer returned $($process.ExitCode), expected $ExpectedExit" }
|
||||
Write-Host "Installer completed in $([math]::Round($watch.Elapsed.TotalSeconds, 1)) seconds."
|
||||
}
|
||||
function Assert-Installed {
|
||||
if (-not (Test-Path (Join-Path $InstallDir 'codex-x.exe'))) { throw 'EXE not installed in original user LOCALAPPDATA.' }
|
||||
$registration = Get-ItemProperty $UninstallKey
|
||||
if ($registration.MainBinaryName -ne 'codex-x.exe') { throw 'Invalid NSIS registration.' }
|
||||
if (@(Related-Products).Count -ne 0) { throw 'Legacy MSI is still registered.' }
|
||||
foreach ($root in @('HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall', 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall')) {
|
||||
$found = @(Get-ChildItem $root -ErrorAction SilentlyContinue | Get-ItemProperty | Where-Object { $_.DisplayName -eq 'Codex-X' })
|
||||
if ($root.StartsWith('HKCU:')) {
|
||||
if ($found.Count -ne 1) { throw 'Expected exactly one current-user uninstall entry.' }
|
||||
} elseif ($found.Count -ne 0) { throw 'A machine-wide Codex-X uninstall entry remains.' }
|
||||
}
|
||||
foreach ($entry in $Sentinels.GetEnumerator()) {
|
||||
if (-not (Test-Path $entry.Key) -or (Get-FileHash $entry.Key -Algorithm SHA256).Hash -ne $entry.Value) {
|
||||
throw "User configuration changed during installation: $($entry.Key)"
|
||||
}
|
||||
}
|
||||
}
|
||||
function Reset-FixtureInstallLocation {
|
||||
# Tauri intentionally preserves this install-location value when keeping
|
||||
# user data. The old MSI's AppSearch overrides command-line INSTALLDIR with
|
||||
# it. Remove only values produced by the previous synthetic install so this
|
||||
# fixture accurately represents a legacy default Program Files installation.
|
||||
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Software\yynxxxxx\Codex-X', $true)
|
||||
if ($null -ne $key) {
|
||||
try {
|
||||
$key.DeleteValue('', $false)
|
||||
$key.DeleteValue('InstallDir', $false)
|
||||
} finally { $key.Dispose() }
|
||||
}
|
||||
}
|
||||
function Remove-TestNsis {
|
||||
# _?= avoids NSIS's detached temporary uninstaller process so WaitForExit
|
||||
# actually covers registry/files removal. This is NSIS's documented syntax.
|
||||
$uninstaller = Join-Path $InstallDir 'uninstall.exe'
|
||||
if (Test-Path $uninstaller) { Invoke-Installer $uninstaller "/S _?=$InstallDir" }
|
||||
if (Test-Path $UninstallKey) { throw 'NSIS uninstall registration was not removed.' }
|
||||
}
|
||||
if ((Test-Path $UninstallKey) -or @(Related-Products).Count -ne 0) { throw 'Runner already has a Codex-X installation.' }
|
||||
$Sentinels = @{}
|
||||
foreach ($relative in @('.codex\config.toml', '.codex\auth.json', '.codexx\installer-smoke-data.txt')) {
|
||||
$file = Join-Path $env:USERPROFILE $relative
|
||||
if (Test-Path $file) { throw "Refusing to overwrite an existing file: $file" }
|
||||
New-Item -ItemType Directory -Force (Split-Path $file) | Out-Null
|
||||
[IO.File]::WriteAllText($file, "Codex-X installer sentinel: $relative")
|
||||
$Sentinels[$file] = (Get-FileHash $file -Algorithm SHA256).Hash
|
||||
}
|
||||
try {
|
||||
Write-Host 'Scenario 1: clean current-user install and subsequent NSIS update.'
|
||||
Invoke-Installer $Installer '/S'
|
||||
Assert-Installed
|
||||
Invoke-Installer $Installer '/S /UPDATE'
|
||||
Assert-Installed
|
||||
Remove-TestNsis
|
||||
Reset-FixtureInstallLocation
|
||||
|
||||
Write-Host 'Scenario 2: released v0.3.20 machine MSI -> current-user NSIS -> NSIS update.'
|
||||
$legacyMsi = Join-Path $Work 'Codex-X-0.3.20.msi'
|
||||
Invoke-WebRequest 'https://github.com/yynxxxxx/Codex-X/releases/download/v0.3.20/Codex-X-0.3.20-windows-x64.msi' -OutFile $legacyMsi
|
||||
if ((Get-FileHash $legacyMsi -Algorithm SHA256).Hash.ToLowerInvariant() -ne $LegacySha256) { throw 'Released MSI hash mismatch.' }
|
||||
Invoke-Installer (Join-Path $env:SystemRoot 'System32\msiexec.exe') "/i `"$legacyMsi`" INSTALLDIR=`"$env:ProgramFiles\Codex-X`" /qn /norestart /L*v `"$Work\legacy-install.log`""
|
||||
if ($LegacyProductCode -notin @(Related-Products)) { throw 'The genuine legacy MSI did not register.' }
|
||||
$oldDirectory = [Text.StringBuilder]::new(1024)
|
||||
[uint32]$oldDirectoryLength = 1024
|
||||
$status = [CodexXMsiSmoke]::MsiGetProductInfoEx($LegacyProductCode, [IntPtr]::Zero, 4, 'InstallLocation', $oldDirectory, [ref]$oldDirectoryLength)
|
||||
if ($status -ne 0 -or $oldDirectory.Length -eq 0) { throw 'Could not read actual legacy MSI InstallLocation.' }
|
||||
$oldPath = $oldDirectory.ToString().TrimEnd('\')
|
||||
Write-Host "Actual legacy MSI InstallLocation: $oldPath"
|
||||
if ($oldPath -ne (Join-Path $env:ProgramFiles 'Codex-X')) { throw 'The legacy fixture was not installed to Program Files.' }
|
||||
$oldExe = Join-Path $oldPath 'codex-x.exe'
|
||||
$oldHash = (Get-FileHash $oldExe).Hash
|
||||
# Guard the old directory, a non-existent child, and a junction alias. None
|
||||
# may remove the registered MSI or overwrite its files.
|
||||
Invoke-Installer $Installer "/S /UPDATE /D=$oldPath" 1
|
||||
Invoke-Installer $Installer "/S /UPDATE /D=$oldPath\unsafe-child" 1
|
||||
$alias = Join-Path $Work 'legacy-junction'
|
||||
New-Item -ItemType Junction -Path $alias -Target $oldPath | Out-Null
|
||||
try {
|
||||
Invoke-Installer $Installer "/S /UPDATE /D=$alias\unsafe-child" 1
|
||||
} finally {
|
||||
[IO.Directory]::Delete($alias)
|
||||
}
|
||||
if ($LegacyProductCode -notin @(Related-Products) -or (Get-FileHash $oldExe).Hash -ne $oldHash) {
|
||||
throw 'A refused overlapping installation changed the legacy app.'
|
||||
}
|
||||
# Keep the real MSI's HKCU manufacturer path to verify that NSIS does not
|
||||
# inherit Program Files when the user accepts the default directory.
|
||||
Invoke-Installer $Installer '/S /UPDATE'
|
||||
Assert-Installed
|
||||
Invoke-Installer $Installer '/S /UPDATE'
|
||||
Assert-Installed
|
||||
Remove-TestNsis
|
||||
Reset-FixtureInstallLocation
|
||||
|
||||
Write-Host 'Scenario 3: MSI returns 3010 after removal; separate NSIS install continues without reboot.'
|
||||
# A separate fixture copy of the hash-verified released MSI requests a reboot
|
||||
# only after REMOVE=ALL. /norestart prevents any OS reboot. This exercises the
|
||||
# genuine Windows Installer 3010 result, not a mocked installer exit code.
|
||||
$rebootMsi = Join-Path $Work 'legacy-deferred-cleanup-fixture.msi'
|
||||
Copy-Item $legacyMsi $rebootMsi
|
||||
$msiAutomation = New-Object -ComObject WindowsInstaller.Installer
|
||||
$database = $msiAutomation.OpenDatabase($rebootMsi, 1)
|
||||
$query = $database.OpenView('INSERT INTO `InstallExecuteSequence` (`Action`, `Condition`, `Sequence`) VALUES (''ScheduleReboot'', ''REMOVE="ALL"'', 6500)')
|
||||
$query.Execute()
|
||||
$query.Close()
|
||||
$summary = $database.GetType().InvokeMember('SummaryInformation', [Reflection.BindingFlags]::GetProperty, $null, $database, @(1))
|
||||
$newPackageCode = '{' + [guid]::NewGuid().ToString().ToUpperInvariant() + '}'
|
||||
[void]$summary.GetType().InvokeMember('Property', [Reflection.BindingFlags]::SetProperty, $null, $summary, @(9, $newPackageCode))
|
||||
$summary.Persist()
|
||||
$database.Commit()
|
||||
foreach ($comObject in @($query, $summary, $database, $msiAutomation)) {
|
||||
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($comObject)
|
||||
}
|
||||
Invoke-Installer (Join-Path $env:SystemRoot 'System32\msiexec.exe') "/i `"$rebootMsi`" INSTALLDIR=`"$env:ProgramFiles\Codex-X`" /qn /norestart /L*v `"$Work\deferred-fixture-install.log`""
|
||||
if ($LegacyProductCode -notin @(Related-Products)) { throw 'The deferred-cleanup MSI fixture did not register.' }
|
||||
$beforeMigration = Get-Date
|
||||
Invoke-Installer $Installer '/S /UPDATE'
|
||||
Assert-Installed
|
||||
$migrationLogs = @(Get-ChildItem (Join-Path $env:LOCALAPPDATA 'Codex-X-updates\logs') -Filter 'install-*.log' |
|
||||
Where-Object { $_.LastWriteTime -ge $beforeMigration -and $_.Name -notlike '*-msi-*' })
|
||||
if (-not ($migrationLogs | Select-String -SimpleMatch 'MSI exit code: 3010')) { throw 'The deferred-cleanup scenario did not actually exercise MSI exit 3010.' }
|
||||
Invoke-Installer $Installer '/S /UPDATE'
|
||||
Assert-Installed
|
||||
Write-Host 'Windows installation smoke tests passed; no reboot needed, configuration sentinels unchanged.'
|
||||
} finally {
|
||||
Get-ChildItem (Join-Path $env:LOCALAPPDATA 'Codex-X-updates\logs') -Filter 'install-*.log' -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
Write-Host "--- $($_.Name) ---"
|
||||
Get-Content $_.FullName -Tail 100
|
||||
}
|
||||
Remove-TestNsis
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# Diagnostic only; installs/removes the hash-pinned fixture on an ephemeral runner.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_OS -ne 'Windows') {
|
||||
throw 'This MSI diagnostic is restricted to an ephemeral Windows Actions runner.'
|
||||
}
|
||||
$work = Join-Path $env:RUNNER_TEMP 'codex-x-msi-probe'
|
||||
New-Item -ItemType Directory -Force $work | Out-Null
|
||||
$legacyMsi = Join-Path $work 'legacy.msi'
|
||||
Invoke-WebRequest 'https://github.com/yynxxxxx/Codex-X/releases/download/v0.3.20/Codex-X-0.3.20-windows-x64.msi' -OutFile $legacyMsi
|
||||
if ((Get-FileHash $legacyMsi -Algorithm SHA256).Hash.ToLowerInvariant() -ne 'e0f2f172a31f860806a9714bab2e67eb38b85d525d7e5f0f569667cbeadfb152') {
|
||||
throw 'MSI fixture hash mismatch.'
|
||||
}
|
||||
$compiler = (Get-Command makensis.exe -ErrorAction SilentlyContinue).Source
|
||||
if (-not $compiler) { $compiler = Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe' }
|
||||
if (-not (Test-Path $compiler)) { throw 'makensis.exe is required for the native MSI probe.' }
|
||||
$psProbe = @'
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type @"
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
public static class MsiProbeNative {
|
||||
[DllImport("msi.dll", CharSet=CharSet.Unicode, ExactSpelling=true)]
|
||||
public static extern uint MsiEnumRelatedProductsW(string upgrade, uint reserved, uint index, StringBuilder product);
|
||||
[DllImport("msi.dll", CharSet=CharSet.Unicode, ExactSpelling=true)]
|
||||
public static extern uint MsiGetProductInfoExW(string product, System.IntPtr sid, uint context, string property, StringBuilder value, ref uint length);
|
||||
}
|
||||
"@
|
||||
$product = [Text.StringBuilder]::new(39)
|
||||
$rc = [MsiProbeNative]::MsiEnumRelatedProductsW('{1482A8E8-9217-517B-8528-93A6C90E0C2F}', 0, 0, $product)
|
||||
Write-Host "PS Is64Bit=$([Environment]::Is64BitProcess) EnumRelated rc=$rc product=$product"
|
||||
foreach ($property in @('VersionString', 'InstallLocation')) {
|
||||
$value = [Text.StringBuilder]::new(1024)
|
||||
[uint32]$length = 1024
|
||||
$rc = [MsiProbeNative]::MsiGetProductInfoExW('{F71E3F3F-A463-4397-AB46-206D3FAC3FBD}', [IntPtr]::Zero, 4, $property, $value, [ref]$length)
|
||||
Write-Host "PS Is64Bit=$([Environment]::Is64BitProcess) machine $property rc=$rc value=$value"
|
||||
}
|
||||
'@
|
||||
$psProbePath = Join-Path $work 'probe-api.ps1'
|
||||
Set-Content $psProbePath $psProbe -Encoding utf8
|
||||
$nsis = @'
|
||||
Unicode true
|
||||
RequestExecutionLevel user
|
||||
OutFile "probe.exe"
|
||||
Name "Codex-X MSI API probe"
|
||||
SilentInstall silent
|
||||
Function .onInit
|
||||
FileOpen $9 "$EXEDIR\nsis-probe.log" w
|
||||
FileWriteUTF16LE /BOM $9 "NSIS x86 Unicode native MSI API probe$\r$\n"
|
||||
System::Call 'msi::MsiEnumRelatedProductsW(w "{1482A8E8-9217-517B-8528-93A6C90E0C2F}",i 0,i 0,w .r0)i.r1'
|
||||
FileWriteUTF16LE $9 "EnumRelatedW result=$1 product=$0$\r$\n"
|
||||
System::Call 'msi::MsiEnumRelatedProductsA(m "{1482A8E8-9217-517B-8528-93A6C90E0C2F}",i 0,i 0,m .r0)i.r1'
|
||||
FileWriteUTF16LE $9 "EnumRelatedA result=$1 product=$0$\r$\n"
|
||||
System::Call 'msi::MsiGetProductInfoExW(w "{F71E3F3F-A463-4397-AB46-206D3FAC3FBD}",p 0,i 4,w "VersionString",w .r0,*i 1024)i.r1'
|
||||
FileWriteUTF16LE $9 "Machine VersionString result=$1 value=$0$\r$\n"
|
||||
System::Call 'msi::MsiGetProductInfoExW(w "{F71E3F3F-A463-4397-AB46-206D3FAC3FBD}",p 0,i 4,w "InstallLocation",w .r0,*i 1024)i.r1'
|
||||
FileWriteUTF16LE $9 "Machine InstallLocation result=$1 value=$0$\r$\n"
|
||||
FileClose $9
|
||||
SetErrorLevel 0
|
||||
Quit
|
||||
FunctionEnd
|
||||
Section
|
||||
SectionEnd
|
||||
'@
|
||||
$nsisPath = Join-Path $work 'probe.nsi'
|
||||
Set-Content $nsisPath $nsis -Encoding utf8
|
||||
function Run-Wait([string]$File, [string]$Arguments) {
|
||||
$process = Start-Process $File -ArgumentList $Arguments -PassThru
|
||||
if (-not $process.WaitForExit(300000)) { throw 'Probe subprocess timed out; no MSI process was terminated.' }
|
||||
$process.Refresh()
|
||||
Write-Host "Process completed: $File, exit=$($process.ExitCode)"
|
||||
if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010) { throw "Subprocess failure: $($process.ExitCode)" }
|
||||
}
|
||||
try {
|
||||
Run-Wait "$env:SystemRoot\System32\msiexec.exe" "/i `"$legacyMsi`" INSTALLDIR=`"$env:ProgramFiles\Codex-X`" /qn /norestart /L*v `"$work\install.log`""
|
||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File $psProbePath
|
||||
& "$env:SystemRoot\SysWOW64\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File $psProbePath
|
||||
& $compiler /V3 $nsisPath
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Native NSIS probe compilation failed.' }
|
||||
Run-Wait (Join-Path $work 'probe.exe') '/S'
|
||||
} finally {
|
||||
$probeLog = Join-Path $work 'nsis-probe.log'
|
||||
if (Test-Path $probeLog) { Get-Content $probeLog }
|
||||
Run-Wait "$env:SystemRoot\System32\msiexec.exe" "/x {F71E3F3F-A463-4397-AB46-206D3FAC3FBD} /qn /norestart /L*v `"$work\uninstall.log`""
|
||||
}
|
||||
Reference in New Issue
Block a user