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 | ||
|
|
71b97bf6b4 |
@@ -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
|
||||
+30
-1
@@ -2,7 +2,36 @@
|
||||
|
||||
记录 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
|
||||
|
||||
- 第三方模型映射后可在 Codex 中选择推理等级,并修复 DeepSeek 每次发送消息先报错、重试后才能使用的问题。
|
||||
- 新增后台配置检查:发现可能导致 Codex 无法使用的问题时提醒你,可选择修复,也可稍后到「设置 → 通用设置」处理。修复前会自动备份。
|
||||
- 会话支持导出为 Markdown,多选会话可打包下载;MCP 和 Skills 支持 ZIP 导出、重新导入,取消 Skills ZIP 的 20 MB 大小限制。
|
||||
- 修复「导入已有」弹窗的暗色显示、无效 MCP 重复出现,以及大日志记录被用量统计跳过的问题。
|
||||
|
||||
## [v0.3.17] - 2026-09-09
|
||||
|
||||
|
||||
@@ -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.17",
|
||||
"version": "0.3.23",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
Generated
+79
-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,13 +492,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.3.17"
|
||||
version = "0.3.23"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"flate2",
|
||||
"getrandom 0.2.17",
|
||||
"httparse",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"rfd",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"semver",
|
||||
@@ -508,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]]
|
||||
@@ -1953,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"
|
||||
@@ -2679,6 +2699,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pollster"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -2917,6 +2943,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.17.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"pollster",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -5606,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.17"
|
||||
version = "0.3.23"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
@@ -38,4 +38,14 @@ rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
sha2 = "0.10"
|
||||
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"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,5 +30,3 @@ pub(crate) const GITHUB_EXAMPLES_API: &str =
|
||||
"https://api.github.com/repos/yynxxxxx/Codex-X/contents/examples?ref=main";
|
||||
pub(crate) const GITHUB_EXAMPLES_BASE: &str =
|
||||
"https://raw.githubusercontent.com/yynxxxxx/Codex-X/main/examples/";
|
||||
|
||||
pub(crate) const MAX_SKILL_ZIP_BYTES: u64 = 20 * 1024 * 1024;
|
||||
|
||||
@@ -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,13 +14,16 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
mod app_db;
|
||||
mod app_update;
|
||||
mod backups;
|
||||
mod ccswitch;
|
||||
mod config_health;
|
||||
mod config_migration;
|
||||
mod constants;
|
||||
mod context_config;
|
||||
mod desktop_lifecycle;
|
||||
mod error;
|
||||
mod failover;
|
||||
mod file_io;
|
||||
mod live_config;
|
||||
mod paths;
|
||||
@@ -33,6 +36,7 @@ mod skills_mcp;
|
||||
mod sqlite_utils;
|
||||
mod state;
|
||||
mod toml_utils;
|
||||
mod transfers;
|
||||
mod updates;
|
||||
mod usage;
|
||||
|
||||
@@ -92,8 +96,8 @@ use providers::{
|
||||
upsert_provider_on_connection, CcSwitchCodexRow, ProviderUpsertKind, ProviderUpsertMode,
|
||||
};
|
||||
use providers::{
|
||||
build_provider_toml_draft_inner, clear_active_provider_on_connection,
|
||||
delete_saved_provider_inner, fetch_provider_models_inner,
|
||||
build_provider_toml_draft_inner, build_provider_toml_draft_with_origin_inner,
|
||||
clear_active_provider_on_connection, delete_saved_provider_inner, fetch_provider_models_inner,
|
||||
import_ccswitch_codex_providers_inner, list_saved_providers_inner, open_store,
|
||||
read_ccswitch_official_auth_inner, remember_active_provider_on_connection,
|
||||
save_active_provider_inner, save_official_config_inner, save_provider_inner,
|
||||
@@ -234,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);
|
||||
@@ -532,6 +542,38 @@ async fn get_startup_diagnostics(config_dir: Option<String>) -> Result<StartupDi
|
||||
.map_err(|e| CodexxError::Config(format!("启动检测失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_codex_config(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<config_health::ConfigHealthReport> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
config_health::check_codex_config_inner(config_dir)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("检查配置失败,请稍后重试。".to_string()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn repair_codex_config(
|
||||
config_dir: Option<String>,
|
||||
expected_fingerprint: String,
|
||||
) -> Result<config_health::ConfigHealthRepairResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
config_health::repair_codex_config_inner(config_dir, expected_fingerprint)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("修复配置失败,请稍后重试。".to_string()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_codex_config_file(config_dir: Option<String>) -> Result<()> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
config_health::open_codex_config_file_inner(config_dir)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("打开配置文件失败,请稍后重试。".to_string()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_session_sync_status(
|
||||
config_dir: Option<String>,
|
||||
@@ -574,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> {
|
||||
@@ -896,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,
|
||||
@@ -944,20 +1011,38 @@ 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,
|
||||
config_dir: Option<String>,
|
||||
new_provider: Option<bool>,
|
||||
) -> Result<String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
build_provider_toml_draft_inner(provider, config_dir)
|
||||
if new_provider.unwrap_or(false) {
|
||||
build_provider_toml_draft_with_origin_inner(provider, config_dir, true)
|
||||
} else {
|
||||
build_provider_toml_draft_inner(provider, config_dir)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("生成供应商 TOML 失败: {e}")))?
|
||||
}
|
||||
|
||||
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]
|
||||
@@ -1003,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}")))?
|
||||
@@ -1023,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}")))?
|
||||
@@ -1038,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]
|
||||
@@ -1058,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}")))?
|
||||
@@ -1114,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]
|
||||
@@ -1138,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}")))?
|
||||
@@ -1156,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}")))?
|
||||
@@ -1169,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}")))?
|
||||
@@ -1357,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
|
||||
@@ -1398,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
|
||||
@@ -1495,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,
|
||||
};
|
||||
@@ -1539,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]
|
||||
@@ -1590,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,
|
||||
@@ -1604,8 +1822,14 @@ pub fn run() {
|
||||
toggle_codex_mcp,
|
||||
save_skills_mcp_note,
|
||||
install_skill_zip,
|
||||
transfers::import_skills_mcp_archive,
|
||||
transfers::export_skills_mcp_archive,
|
||||
transfers::export_codex_sessions,
|
||||
check_skill_updates,
|
||||
get_startup_diagnostics,
|
||||
check_codex_config,
|
||||
repair_codex_config,
|
||||
open_codex_config_file,
|
||||
get_session_sync_status,
|
||||
sync_sessions_provider,
|
||||
delete_codex_sessions,
|
||||
@@ -1621,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,
|
||||
@@ -1661,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
@@ -7,6 +7,7 @@ pub(crate) mod official_profiles;
|
||||
pub(crate) mod quota;
|
||||
mod selection;
|
||||
mod store;
|
||||
pub(crate) mod transport;
|
||||
|
||||
use crate::error::Result;
|
||||
use rusqlite::Connection;
|
||||
@@ -26,12 +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, delete_saved_provider_inner,
|
||||
save_active_provider_inner, save_official_config_inner, save_provider_toml_config_inner,
|
||||
switch_provider_inner, OfficialConfigInput, ProviderInput, ProviderTomlInput,
|
||||
activate_saved_provider_inner, build_provider_toml_draft_inner,
|
||||
build_provider_toml_draft_with_origin_inner, delete_saved_provider_inner,
|
||||
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,
|
||||
@@ -55,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};
|
||||
@@ -22,6 +26,9 @@ const CATALOG_DIRECTORY: &str = "model-catalogs";
|
||||
const CATALOG_FIELD: &str = "model_catalog_json";
|
||||
const OWNERSHIP_FIELD: &str = "_codex_x_model_catalog";
|
||||
const MAX_CATALOG_BYTES: u64 = 1024 * 1024;
|
||||
const REASONING_EFFORTS: &[&str] = &[
|
||||
"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra",
|
||||
];
|
||||
const BASE_INSTRUCTIONS: &str = "You are a coding assistant working with the user in a shared workspace. Use the available tools to inspect and edit project files, follow the user's requirements, and verify your changes.";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -34,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() {
|
||||
@@ -108,14 +122,16 @@ 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, vision, or reasoning promises.
|
||||
// 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,
|
||||
"description": mapping.display_name,
|
||||
"base_instructions": BASE_INSTRUCTIONS,
|
||||
"default_reasoning_level": null,
|
||||
"supported_reasoning_levels": [],
|
||||
"default_reasoning_level": "high",
|
||||
"supported_reasoning_levels": REASONING_EFFORTS.iter().map(|effort| json!({"effort": effort, "description": if *effort == "none" { "Disable thinking".to_owned() } else { format!("{effort} reasoning effort") }})).collect::<Vec<_>>(),
|
||||
"shell_type": "shell_command",
|
||||
"visibility": "list",
|
||||
"supported_in_api": true,
|
||||
@@ -124,7 +140,8 @@ fn model_entry(mapping: &ProviderModelMapping, priority: usize, default_context:
|
||||
"service_tiers": [],
|
||||
"availability_nux": null,
|
||||
"upgrade": null,
|
||||
"supports_reasoning_summaries": false,
|
||||
// Older Codex gates the entire reasoning object (including effort) here.
|
||||
"supports_reasoning_summaries": true,
|
||||
"supports_reasoning_summary_parameter": false,
|
||||
"default_reasoning_summary": "none",
|
||||
"support_verbosity": false,
|
||||
@@ -137,9 +154,10 @@ 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
|
||||
"use_responses_lite": false,
|
||||
"prefer_websockets": false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -338,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);
|
||||
@@ -349,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)]
|
||||
@@ -404,6 +510,60 @@ mod tests {
|
||||
serde_json::from_slice(&fs::read(path(doc)).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_mapped_model_has_the_complete_effort_menu_without_extra_settings() {
|
||||
for id in [
|
||||
"deepseek-flash",
|
||||
"deepseek-v4-flash",
|
||||
"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"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|level| level["effort"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(levels, REASONING_EFFORTS);
|
||||
assert_eq!(entry["default_reasoning_level"], "high");
|
||||
assert_eq!(entry["supports_reasoning_summaries"], true);
|
||||
assert_eq!(entry["supports_reasoning_summary_parameter"], false);
|
||||
assert_eq!(entry["default_reasoning_summary"], "none");
|
||||
assert_eq!(entry["prefer_websockets"], false);
|
||||
assert!(!entry.to_string().contains("Maps to"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_saved_rows_need_no_additional_reasoning_configuration() {
|
||||
let fixture = Fixture::new();
|
||||
for payload in [
|
||||
json!({"model":"custom-model"}),
|
||||
json!({"model":"custom-model","reasoningEfforts":[]}),
|
||||
] {
|
||||
let row: ProviderModelMapping = serde_json::from_value(payload).unwrap();
|
||||
let mut doc = DocumentMut::new();
|
||||
prepare_model_catalog(&fixture.0, "provider", &[row], "custom-model", &mut doc)
|
||||
.unwrap();
|
||||
let generated = catalog(&doc);
|
||||
assert_eq!(
|
||||
generated["models"][0]["supported_reasoning_levels"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
8
|
||||
);
|
||||
assert_eq!(
|
||||
generated["models"][0]["supported_reasoning_levels"][0]["effort"],
|
||||
"none"
|
||||
);
|
||||
assert_eq!(generated["models"][0]["default_reasoning_level"], "high");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalization_preserves_real_models_and_adds_missing_default() {
|
||||
let result = normalize_mappings(
|
||||
@@ -478,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"));
|
||||
@@ -546,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,
|
||||
@@ -781,6 +870,7 @@ fn sync_provider_toml_from_fields(provider: &mut SavedProvider) -> Result<()> {
|
||||
CodexxError::Config(format!("供应商 TOML 缺少 [model_providers.{provider_id}]"))
|
||||
})?;
|
||||
table["name"] = value(provider.provider_name.clone());
|
||||
super::transport::configure_third_party_transport(table, &provider.base_url, true);
|
||||
table["base_url"] = value(provider.base_url.clone());
|
||||
table["wire_api"] = value(provider.wire_api.clone());
|
||||
table["requires_openai_auth"] = value(provider.requires_openai_auth);
|
||||
@@ -1234,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();
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
use toml_edit::{value, Item, TableLike};
|
||||
|
||||
/// Match the official API exactly. A proxy that forwards DeepSeek may support
|
||||
/// WebSockets independently, so model names and partial host matches are unsafe.
|
||||
pub(crate) fn is_deepseek_http_endpoint(base_url: &str) -> bool {
|
||||
reqwest::Url::parse(base_url.trim()).is_ok_and(|url| {
|
||||
matches!(url.scheme(), "http" | "https") && url.host_str() == Some("api.deepseek.com")
|
||||
})
|
||||
}
|
||||
|
||||
/// A provider's WebSocket capability belongs to its endpoint, not the common
|
||||
/// Codex config inherited when creating a provider. Call before replacing URL.
|
||||
pub(super) fn configure_third_party_transport(
|
||||
table: &mut dyn TableLike,
|
||||
base_url: &str,
|
||||
own_template: bool,
|
||||
) {
|
||||
let same_endpoint = table
|
||||
.get("base_url")
|
||||
.and_then(Item::as_str)
|
||||
.is_some_and(|old| {
|
||||
super::store::canonical_provider_base_url(old)
|
||||
== super::store::canonical_provider_base_url(base_url)
|
||||
});
|
||||
let preserve_explicit = own_template
|
||||
&& same_endpoint
|
||||
&& !is_deepseek_http_endpoint(base_url)
|
||||
&& table
|
||||
.get("supports_websockets")
|
||||
.and_then(Item::as_bool)
|
||||
.is_some();
|
||||
if !preserve_explicit {
|
||||
// Explicit false also overrides Codex's built-in provider defaults if a
|
||||
// user-chosen provider ID happens to shadow a built-in provider.
|
||||
let decor = table
|
||||
.get("supports_websockets")
|
||||
.and_then(Item::as_value)
|
||||
.map(|value| value.decor().clone());
|
||||
let mut replacement = value(false);
|
||||
if let Some(decor) = decor {
|
||||
*replacement
|
||||
.as_value_mut()
|
||||
.expect("boolean value")
|
||||
.decor_mut() = decor;
|
||||
}
|
||||
table.insert("supports_websockets", replacement);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use toml_edit::Table;
|
||||
|
||||
#[test]
|
||||
fn deepseek_host_matching_does_not_disable_proxies() {
|
||||
assert!(is_deepseek_http_endpoint("https://api.deepseek.com/v1/"));
|
||||
assert!(is_deepseek_http_endpoint("HTTPS://API.DEEPSEEK.COM"));
|
||||
for endpoint in [
|
||||
"https://deepseek.proxy.example/v1",
|
||||
"https://api.deepseek.com.proxy.example/v1",
|
||||
"https://api.deepseek.com@proxy.example/v1",
|
||||
"https://proxy.example/api.deepseek.com",
|
||||
"invalid api.deepseek.com",
|
||||
] {
|
||||
assert!(!is_deepseek_http_endpoint(endpoint), "{endpoint}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_ownership_controls_websocket_inheritance() {
|
||||
for (source, destination, own, expected) in [
|
||||
(
|
||||
"https://proxy.example/v1",
|
||||
"https://proxy.example/v1/",
|
||||
true,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"https://proxy.example/v1",
|
||||
"https://proxy.example/v1",
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"https://old.example/v1",
|
||||
"https://new.example/v1",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"https://api.deepseek.com",
|
||||
"https://api.deepseek.com",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let mut table = Table::new();
|
||||
table["base_url"] = value(source);
|
||||
table["supports_websockets"] = value(true);
|
||||
table["request_max_retries"] = value(9);
|
||||
configure_third_party_transport(&mut table, destination, own);
|
||||
assert_eq!(table["supports_websockets"].as_bool(), Some(expected));
|
||||
assert_eq!(table["request_max_retries"].as_integer(), Some(9));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +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;
|
||||
@@ -15,14 +18,16 @@ pub(crate) use backup::{
|
||||
#[cfg(test)]
|
||||
pub(crate) use delete::{active_session_ids_present, hard_delete_sessions_locally};
|
||||
pub(crate) use delete::{delete_codex_sessions_inner, SessionDeleteInput, SessionDeleteResult};
|
||||
pub(crate) use export::{export_codex_sessions_inner, SessionExportResult};
|
||||
#[cfg(test)]
|
||||
pub(crate) use storage::{
|
||||
apply_session_changes, list_session_previews, restore_session_changes, scan_rollouts,
|
||||
scan_sqlite, sqlite_session_db_paths,
|
||||
};
|
||||
pub(crate) use storage::{
|
||||
session_project_title, session_titles_by_id, source_value_is_subagent, sqlite_candidate_paths,
|
||||
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)]
|
||||
|
||||
@@ -0,0 +1,993 @@
|
||||
use super::build_skills_mcp_state_inner;
|
||||
use super::mcp::{
|
||||
commit_mcp_transaction_with_config, document_bytes, is_valid_mcp_config, json_to_toml_item,
|
||||
mcp_configs_equal, save_managed_mcp_on_connection,
|
||||
};
|
||||
use super::skills::{
|
||||
codex_skills_dir, copy_dir_recursive, disabled_skills_dir, read_skill_metadata,
|
||||
sanitize_dir_name,
|
||||
};
|
||||
use super::types::{SkillsMcpActionResult, SkillsMcpExportResult};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, parse_toml_document};
|
||||
use crate::live_config::{acquire_live_config_lock, read_file_snapshot, text_from_snapshot};
|
||||
use crate::paths::{app_home, normalized_path_scope};
|
||||
use crate::toml_utils::ensure_table;
|
||||
use crate::{config_path, now_rfc3339, open_db, resolve_codex_dir};
|
||||
use rusqlite::{params, TransactionBehavior};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Seek};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
const MANIFEST: &str = "codex-x-archive.json";
|
||||
const FORMAT: &str = "codex-x-skills-mcp";
|
||||
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct Manifest {
|
||||
format: String,
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
skills: Vec<SkillEntry>,
|
||||
#[serde(default)]
|
||||
mcp_servers: Vec<McpEntry>,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct SkillEntry {
|
||||
directory: String,
|
||||
path: String,
|
||||
enabled: bool,
|
||||
note: Option<String>,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct McpEntry {
|
||||
id: String,
|
||||
name: String,
|
||||
config: Value,
|
||||
enabled: bool,
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> CodexxError {
|
||||
CodexxError::Config(message.into())
|
||||
}
|
||||
fn zip_error(error: impl std::fmt::Display) -> CodexxError {
|
||||
invalid(format!("无法读取或写入 ZIP:{error}"))
|
||||
}
|
||||
fn unique_name(prefix: &str) -> String {
|
||||
format!(
|
||||
".{prefix}-{}-{}-{}",
|
||||
std::process::id(),
|
||||
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default(),
|
||||
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
|
||||
)
|
||||
}
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn new(parent: &Path) -> Result<Self> {
|
||||
ensure_directory(parent)?;
|
||||
let path = parent.join(unique_name("codex-x-archive"));
|
||||
fs::create_dir(&path).map_err(|error| io_err(&path, error))?;
|
||||
Ok(Self(path))
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Reject Windows traversal/drive names on every OS too, so exported bundles can
|
||||
// safely move between machines. Links and special files are never extracted.
|
||||
fn safe_relative(name: &str) -> Result<PathBuf> {
|
||||
if name.is_empty() || name.contains('\\') || name.contains(':') || name.contains('\0') {
|
||||
return Err(invalid("ZIP 中含有不安全的文件路径"));
|
||||
}
|
||||
let path = Path::new(name);
|
||||
if path
|
||||
.components()
|
||||
.any(|part| !matches!(part, Component::Normal(_)))
|
||||
{
|
||||
return Err(invalid("ZIP 中含有不安全的文件路径"));
|
||||
}
|
||||
for part in name.trim_end_matches('/').split('/') {
|
||||
let stem = part.split('.').next().unwrap_or("").to_ascii_uppercase();
|
||||
if part.is_empty()
|
||||
|| part.ends_with([' ', '.'])
|
||||
|| matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| (stem.len() == 4
|
||||
&& (stem.starts_with("COM") || stem.starts_with("LPT"))
|
||||
&& stem.as_bytes()[3].is_ascii_digit())
|
||||
{
|
||||
return Err(invalid("ZIP 中含有无法跨平台安全使用的文件路径"));
|
||||
}
|
||||
}
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
fn safe_directory(name: &str) -> Result<()> {
|
||||
let path = safe_relative(name)?;
|
||||
if path.components().count() != 1 || name.starts_with('.') {
|
||||
return Err(invalid("归档中的 Skill 目录名无效"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract<R: Read + Seek>(reader: R, destination: &Path) -> Result<()> {
|
||||
let mut archive = zip::ZipArchive::new(reader).map_err(zip_error)?;
|
||||
let mut seen = HashSet::new();
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive.by_index(index).map_err(zip_error)?;
|
||||
let name = entry.name().trim_end_matches('/');
|
||||
let relative = safe_relative(name)?;
|
||||
let mode = entry.unix_mode().unwrap_or(0) & 0o170000;
|
||||
if mode != 0 && mode != 0o100000 && mode != 0o040000 {
|
||||
return Err(invalid("ZIP 不支持符号链接或特殊文件"));
|
||||
}
|
||||
if !seen.insert(name.to_ascii_lowercase()) {
|
||||
return Err(invalid("ZIP 中含有重复文件路径"));
|
||||
}
|
||||
let output = destination.join(relative);
|
||||
if entry.is_dir() {
|
||||
ensure_directory(&output)?;
|
||||
} else {
|
||||
ensure_directory(output.parent().ok_or_else(|| invalid("ZIP 路径无效"))?)?;
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&output)
|
||||
.map_err(|error| io_err(&output, error))?;
|
||||
std::io::copy(&mut entry, &mut file).map_err(|error| io_err(&output, error))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Some(mode) = entry.unix_mode() {
|
||||
fs::set_permissions(&output, fs::Permissions::from_mode(mode & 0o777))
|
||||
.map_err(|error| io_err(&output, error))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_entries(root: &Path, current: &Path, output: &mut Vec<PathBuf>) -> Result<()> {
|
||||
let metadata = fs::symlink_metadata(current).map_err(|error| io_err(current, error))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(invalid("Skill 包含符号链接,请先替换为实际文件再导出"));
|
||||
}
|
||||
if metadata.is_file() {
|
||||
output.push(
|
||||
current
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| invalid("Skill 文件路径无效"))?
|
||||
.to_path_buf(),
|
||||
);
|
||||
} else if metadata.is_dir() {
|
||||
if current != root {
|
||||
output.push(
|
||||
current
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| invalid("Skill 目录路径无效"))?
|
||||
.to_path_buf(),
|
||||
);
|
||||
}
|
||||
for entry in fs::read_dir(current).map_err(|error| io_err(current, error))? {
|
||||
collect_entries(
|
||||
root,
|
||||
&entry.map_err(|error| io_err(current, error))?.path(),
|
||||
output,
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
return Err(invalid("Skill 包含不支持的特殊文件"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn directory_digest(path: &Path) -> Result<Vec<u8>> {
|
||||
let mut files = Vec::new();
|
||||
collect_entries(path, path, &mut files)?;
|
||||
files.sort();
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
for relative in files {
|
||||
let name = relative.to_string_lossy().replace('\\', "/");
|
||||
digest.update((name.len() as u64).to_le_bytes());
|
||||
digest.update(name.as_bytes());
|
||||
let file_path = path.join(relative);
|
||||
if file_path.is_dir() {
|
||||
digest.update(b"directory");
|
||||
continue;
|
||||
}
|
||||
digest.update(b"file");
|
||||
let mut file = File::open(&file_path).map_err(|error| io_err(&file_path, error))?;
|
||||
digest.update(
|
||||
file.metadata()
|
||||
.map_err(|error| io_err(&file_path, error))?
|
||||
.len()
|
||||
.to_le_bytes(),
|
||||
);
|
||||
loop {
|
||||
let count = file
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| io_err(&file_path, error))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buffer[..count]);
|
||||
}
|
||||
}
|
||||
Ok(digest.finalize().to_vec())
|
||||
}
|
||||
fn write_archive(path: &Path, manifest: &Manifest, sources: &[PathBuf]) -> Result<()> {
|
||||
let file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
}
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.large_file(true);
|
||||
writer.start_file(MANIFEST, options).map_err(zip_error)?;
|
||||
serde_json::to_writer_pretty(&mut writer, manifest).map_err(|_| invalid("无法写入归档清单"))?;
|
||||
for (entry, source) in manifest.skills.iter().zip(sources) {
|
||||
let mut files = Vec::new();
|
||||
collect_entries(source, source, &mut files)?;
|
||||
files.sort();
|
||||
for relative in files {
|
||||
let relative_name = relative.to_string_lossy().replace('\\', "/");
|
||||
safe_relative(&relative_name)?;
|
||||
let source_file = source.join(&relative);
|
||||
let mut options = options;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
options = options.unix_permissions(
|
||||
fs::metadata(&source_file)
|
||||
.map_err(|error| io_err(&source_file, error))?
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
);
|
||||
}
|
||||
if source_file.is_dir() {
|
||||
writer
|
||||
.add_directory(format!("{}/{}/", entry.path, relative_name), options)
|
||||
.map_err(zip_error)?;
|
||||
continue;
|
||||
}
|
||||
writer
|
||||
.start_file(format!("{}/{}", entry.path, relative_name), options)
|
||||
.map_err(zip_error)?;
|
||||
let mut input =
|
||||
File::open(&source_file).map_err(|error| io_err(&source_file, error))?;
|
||||
std::io::copy(&mut input, &mut writer).map_err(|error| io_err(path, error))?;
|
||||
}
|
||||
}
|
||||
writer
|
||||
.finish()
|
||||
.map_err(zip_error)?
|
||||
.sync_all()
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn destination_snapshot(path: &Path) -> Result<Option<Vec<u8>>> {
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(io_err(path, error)),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err(invalid("请选择普通 ZIP 文件路径,不能覆盖符号链接或目录"));
|
||||
}
|
||||
let mut file = File::open(path).map_err(|error| io_err(path, error))?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let count = file
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buffer[..count]);
|
||||
}
|
||||
Ok(Some(digest.finalize().to_vec()))
|
||||
}
|
||||
|
||||
fn write_archive_atomic<BeforeReplace: FnOnce() -> Result<()>>(
|
||||
output: &Path,
|
||||
manifest: &Manifest,
|
||||
sources: &[PathBuf],
|
||||
before_replace: BeforeReplace,
|
||||
) -> Result<()> {
|
||||
let initial = destination_snapshot(output)?;
|
||||
let parent = output
|
||||
.parent()
|
||||
.ok_or_else(|| invalid("请选择有效的保存目录"))?;
|
||||
let temporary = parent.join(unique_name("codex-x-export"));
|
||||
let result = (|| {
|
||||
write_archive(&temporary, manifest, sources)?;
|
||||
before_replace()?;
|
||||
if destination_snapshot(output)? != initial {
|
||||
return Err(invalid(
|
||||
"保存位置的文件已被其他程序修改,请重新选择位置再导出",
|
||||
));
|
||||
}
|
||||
fs::rename(&temporary, output).map_err(|error| io_err(output, error))
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn validate_export_destination(output: &Path, codex_dir: &Path) -> Result<PathBuf> {
|
||||
if !output
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("zip"))
|
||||
{
|
||||
return Err(invalid("请将导出文件保存为 .zip 格式"));
|
||||
}
|
||||
let parent = output
|
||||
.parent()
|
||||
.filter(|parent| parent.is_dir())
|
||||
.ok_or_else(|| invalid("请选择有效的保存目录"))?;
|
||||
let canonical_parent = fs::canonicalize(parent).map_err(|error| io_err(parent, error))?;
|
||||
for protected in [codex_dir.to_path_buf(), app_home()?] {
|
||||
let canonical = fs::canonicalize(&protected).unwrap_or(protected);
|
||||
if canonical_parent.starts_with(canonical) {
|
||||
return Err(invalid(
|
||||
"请将 ZIP 保存到下载、桌面等目录,不要放入 Codex 配置或应用数据目录",
|
||||
));
|
||||
}
|
||||
}
|
||||
destination_snapshot(output)?;
|
||||
Ok(canonical_parent)
|
||||
}
|
||||
|
||||
pub(crate) fn export_skills_mcp_archive_inner(
|
||||
config_dir: Option<String>,
|
||||
kind: String,
|
||||
destination: String,
|
||||
) -> Result<SkillsMcpExportResult> {
|
||||
if !matches!(kind.as_str(), "skills" | "mcp") {
|
||||
return Err(invalid("请选择导出 Skills 或 MCP"));
|
||||
}
|
||||
let state = build_skills_mcp_state_inner(config_dir)?;
|
||||
let mut manifest = Manifest {
|
||||
format: FORMAT.to_owned(),
|
||||
version: 1,
|
||||
skills: vec![],
|
||||
mcp_servers: vec![],
|
||||
};
|
||||
let mut sources = Vec::new();
|
||||
if kind == "skills" {
|
||||
for skill in state.skills {
|
||||
safe_directory(&skill.directory)?;
|
||||
manifest.skills.push(SkillEntry {
|
||||
path: format!("skills/{}", skill.directory),
|
||||
directory: skill.directory,
|
||||
enabled: skill.enabled,
|
||||
note: skill.note,
|
||||
});
|
||||
sources.push(PathBuf::from(skill.path));
|
||||
}
|
||||
} else {
|
||||
for mcp in state.mcp_servers {
|
||||
manifest.mcp_servers.push(McpEntry {
|
||||
id: mcp.id,
|
||||
name: mcp.name,
|
||||
config: mcp.config_json,
|
||||
enabled: mcp.enabled,
|
||||
note: mcp.note,
|
||||
});
|
||||
}
|
||||
}
|
||||
if manifest.skills.is_empty() && manifest.mcp_servers.is_empty() {
|
||||
return Err(invalid("当前没有可导出的内容"));
|
||||
}
|
||||
let output = PathBuf::from(destination);
|
||||
let canonical_parent = validate_export_destination(&output, Path::new(&state.codex_dir))?;
|
||||
for source in &sources {
|
||||
let canonical_source = fs::canonicalize(source).map_err(|error| io_err(source, error))?;
|
||||
if canonical_parent.starts_with(canonical_source) {
|
||||
return Err(invalid("请将 ZIP 保存在 Skill 目录之外"));
|
||||
}
|
||||
}
|
||||
write_archive_atomic(&output, &manifest, &sources, || Ok(()))?;
|
||||
Ok(SkillsMcpExportResult {
|
||||
path: output.display().to_string(),
|
||||
exported_skills: manifest.skills.len(),
|
||||
exported_mcp: manifest.mcp_servers.len(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn install_skill_archive_path_inner(
|
||||
config_dir: Option<String>,
|
||||
path: String,
|
||||
) -> Result<SkillsMcpActionResult> {
|
||||
let path = Path::new(&path);
|
||||
let file = File::open(path).map_err(|error| io_err(path, error))?;
|
||||
install_archive_reader(
|
||||
config_dir,
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("skills.zip")
|
||||
.to_owned(),
|
||||
file,
|
||||
)
|
||||
}
|
||||
|
||||
fn find_skill_roots(path: &Path, output: &mut Vec<PathBuf>) -> Result<()> {
|
||||
if path.join("SKILL.md").is_file() {
|
||||
output.push(path.to_path_buf());
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(path).map_err(|error| io_err(path, error))? {
|
||||
let entry = entry.map_err(|error| io_err(path, error))?;
|
||||
if entry
|
||||
.file_type()
|
||||
.map_err(|error| io_err(path, error))?
|
||||
.is_dir()
|
||||
{
|
||||
find_skill_roots(&entry.path(), output)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn manifest_from_extracted(path: &Path, file_name: &str) -> Result<Manifest> {
|
||||
let manifest_path = path.join(MANIFEST);
|
||||
if manifest_path.exists() {
|
||||
let file = File::open(&manifest_path).map_err(|error| io_err(&manifest_path, error))?;
|
||||
// This limits only metadata, not ZIP contents or Skill file sizes.
|
||||
let manifest: Manifest = serde_json::from_reader(file.take(8 * 1024 * 1024))
|
||||
.map_err(|_| invalid("Codex-X 归档清单无效或过大"))?;
|
||||
if manifest.format != FORMAT || manifest.version != 1 {
|
||||
return Err(invalid("暂不支持此归档版本,请更新 Codex-X"));
|
||||
}
|
||||
return Ok(manifest);
|
||||
}
|
||||
let mut roots = Vec::new();
|
||||
find_skill_roots(path, &mut roots)?;
|
||||
let mut manifest = Manifest {
|
||||
format: FORMAT.to_owned(),
|
||||
version: 1,
|
||||
skills: vec![],
|
||||
mcp_servers: vec![],
|
||||
};
|
||||
for root in roots {
|
||||
let fallback = if root == path {
|
||||
file_name.trim_end_matches(".zip")
|
||||
} else {
|
||||
root.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(file_name.trim_end_matches(".zip"))
|
||||
};
|
||||
let (name, _) = read_skill_metadata(&root, fallback);
|
||||
let relative = root
|
||||
.strip_prefix(path)
|
||||
.map_err(|_| invalid("Skill 路径无效"))?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
manifest.skills.push(SkillEntry {
|
||||
directory: sanitize_dir_name(&name, "skill"),
|
||||
path: relative,
|
||||
enabled: true,
|
||||
note: None,
|
||||
});
|
||||
}
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
pub(super) fn install_archive_reader<R: Read + Seek>(
|
||||
config_dir: Option<String>,
|
||||
file_name: String,
|
||||
reader: R,
|
||||
) -> Result<SkillsMcpActionResult> {
|
||||
let temporary = TempDir::new(&app_home()?.join("tmp"))?;
|
||||
extract(reader, &temporary.0)?;
|
||||
let manifest = manifest_from_extracted(&temporary.0, &file_name)?;
|
||||
let (imported_skills, imported_mcp, skipped) =
|
||||
import_manifest(config_dir.clone(), &temporary.0, manifest)?;
|
||||
Ok(SkillsMcpActionResult {
|
||||
imported_skills,
|
||||
imported_mcp,
|
||||
message: format!(
|
||||
"已导入 {imported_skills} 个 Skills、{imported_mcp} 个 MCP{}",
|
||||
if skipped > 0 {
|
||||
format!(",跳过 {skipped} 个已存在的项目")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
),
|
||||
state: build_skills_mcp_state_inner(config_dir)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn import_manifest(
|
||||
config_dir: Option<String>,
|
||||
root: &Path,
|
||||
manifest: Manifest,
|
||||
) -> Result<(usize, usize, usize)> {
|
||||
if manifest.skills.is_empty() && manifest.mcp_servers.is_empty() {
|
||||
return Err(invalid("ZIP 中没有可导入的 Skills 或 MCP"));
|
||||
}
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
ensure_directory(&codex_dir)?;
|
||||
let _lock = acquire_live_config_lock(&codex_dir)?;
|
||||
let current = build_skills_mcp_state_inner(config_dir)?;
|
||||
let cfg = config_path(&codex_dir);
|
||||
let before = read_file_snapshot(&cfg)?;
|
||||
let mut document = parse_toml_document(&cfg, &text_from_snapshot(&cfg, before.as_deref())?)?;
|
||||
let mut skill_ids = HashSet::new();
|
||||
let mut mcp_ids = HashSet::new();
|
||||
let existing_skills: HashMap<_, _> = current
|
||||
.skills
|
||||
.iter()
|
||||
.map(|skill| (skill.directory.as_str(), skill))
|
||||
.collect();
|
||||
let existing_mcp: HashMap<_, _> = current
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.map(|mcp| (mcp.id.as_str(), mcp))
|
||||
.collect();
|
||||
let mut skill_plan = Vec::new();
|
||||
let mut mcp_plan = Vec::new();
|
||||
let mut skipped = 0;
|
||||
for skill in manifest.skills {
|
||||
safe_directory(&skill.directory)?;
|
||||
if !skill_ids.insert(sanitize_dir_name(&skill.directory, "skill")) {
|
||||
return Err(invalid("归档中存在同名 Skill"));
|
||||
}
|
||||
let relative = if skill.path.is_empty() {
|
||||
PathBuf::new()
|
||||
} else {
|
||||
safe_relative(&skill.path)?
|
||||
};
|
||||
let source = root.join(relative);
|
||||
if !source.join("SKILL.md").is_file() {
|
||||
return Err(invalid(format!("Skill {} 缺少 SKILL.md", skill.directory)));
|
||||
}
|
||||
if let Some(existing) = existing_skills.get(skill.directory.as_str()) {
|
||||
if directory_digest(&source)? == directory_digest(Path::new(&existing.path))? {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(invalid(format!(
|
||||
"Skill「{}」已存在且内容不同。请先重命名或移除已有项目,导入不会覆盖它。",
|
||||
skill.directory
|
||||
)));
|
||||
}
|
||||
let parent = if skill.enabled {
|
||||
codex_skills_dir(&codex_dir)
|
||||
} else {
|
||||
disabled_skills_dir()?
|
||||
};
|
||||
let destination = parent.join(&skill.directory);
|
||||
if destination.exists() {
|
||||
return Err(invalid(format!(
|
||||
"Skill 目录「{}」已存在,未覆盖",
|
||||
skill.directory
|
||||
)));
|
||||
}
|
||||
skill_plan.push((skill, source, destination));
|
||||
}
|
||||
for mcp in manifest.mcp_servers {
|
||||
if mcp.id.trim().is_empty()
|
||||
|| !mcp_ids.insert(mcp.id.clone())
|
||||
|| !is_valid_mcp_config(&mcp.config)
|
||||
{
|
||||
return Err(invalid("归档中存在无效或重复的 MCP 配置"));
|
||||
}
|
||||
if let Some(existing) = existing_mcp.get(mcp.id.as_str()) {
|
||||
if mcp_configs_equal(&existing.config_json, &mcp.config) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(invalid(format!(
|
||||
"MCP「{}」已存在且配置不同。请先重命名或移除已有项目,导入不会覆盖它。",
|
||||
mcp.name
|
||||
)));
|
||||
}
|
||||
// A malformed live entry is still user data; do not overwrite it.
|
||||
if document
|
||||
.get("mcp_servers")
|
||||
.and_then(|item| item.as_table())
|
||||
.is_some_and(|table| table.contains_key(&mcp.id))
|
||||
{
|
||||
return Err(invalid(format!("MCP「{}」已存在,未覆盖", mcp.name)));
|
||||
}
|
||||
mcp_plan.push(mcp);
|
||||
}
|
||||
let mut connection = open_db()?;
|
||||
let transaction = connection
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
let scope = normalized_path_scope(&codex_dir);
|
||||
let save_note = |kind: &str, id: &str, note: &Option<String>| -> Result<()> {
|
||||
if let Some(note) = note.as_deref().filter(|note| !note.trim().is_empty()) {
|
||||
if note.chars().count() > super::SKILLS_MCP_NOTE_MAX_CHARS {
|
||||
return Err(invalid("归档中的备注过长"));
|
||||
}
|
||||
transaction.execute("INSERT INTO skills_mcp_notes (codex_dir,item_kind,item_id,note,updated_at) VALUES (?1,?2,?3,?4,?5) ON CONFLICT(codex_dir,item_kind,item_id) DO NOTHING", params![scope, kind, id, note, now_rfc3339()]).map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
for mcp in &mcp_plan {
|
||||
save_managed_mcp_on_connection(&transaction, &mcp.id, &mcp.name, &mcp.config, mcp.enabled)?;
|
||||
if mcp.enabled {
|
||||
ensure_table(document.as_table_mut(), "mcp_servers")?
|
||||
.insert(&mcp.id, json_to_toml_item(&mcp.config));
|
||||
}
|
||||
save_note("mcp", &mcp.id, &mcp.note)?;
|
||||
}
|
||||
for (skill, _, _) in &skill_plan {
|
||||
save_note(
|
||||
"skill",
|
||||
&sanitize_dir_name(&skill.directory, "skill"),
|
||||
&skill.note,
|
||||
)?;
|
||||
}
|
||||
let mut created = Vec::new();
|
||||
let apply = (|| -> Result<()> {
|
||||
for (_, source, destination) in &skill_plan {
|
||||
let staging = TempDir::new(
|
||||
destination
|
||||
.parent()
|
||||
.ok_or_else(|| invalid("Skill 目标目录无效"))?,
|
||||
)?;
|
||||
copy_dir_recursive(source, &staging.0)?;
|
||||
if destination.exists() {
|
||||
return Err(invalid("导入期间出现同名 Skill,已停止且未覆盖"));
|
||||
}
|
||||
fs::rename(&staging.0, destination).map_err(|error| io_err(destination, error))?;
|
||||
created.push(destination.clone());
|
||||
}
|
||||
let after = document_bytes(before.as_deref(), &document);
|
||||
commit_mcp_transaction_with_config(
|
||||
transaction,
|
||||
&codex_dir,
|
||||
before,
|
||||
after,
|
||||
"import-skills-mcp-zip",
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
)
|
||||
})();
|
||||
if apply.is_err() {
|
||||
for path in created {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
}
|
||||
apply?;
|
||||
Ok((skill_plan.len(), mcp_plan.len(), skipped))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
fn manifest() -> Manifest {
|
||||
Manifest {
|
||||
format: FORMAT.into(),
|
||||
version: 1,
|
||||
skills: vec![],
|
||||
mcp_servers: vec![],
|
||||
}
|
||||
}
|
||||
fn test_dir() -> TempDir {
|
||||
TempDir::new(&std::env::temp_dir().join("codex-x-archive-tests")).unwrap()
|
||||
}
|
||||
fn codex(dir: &Path) -> Option<String> {
|
||||
fs::create_dir_all(dir).unwrap();
|
||||
Some(dir.display().to_string())
|
||||
}
|
||||
fn skill(root: &Path, directory: &str, enabled: bool) -> SkillEntry {
|
||||
let path = root.join("skills").join(directory);
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
fs::write(
|
||||
path.join("SKILL.md"),
|
||||
format!("---\nname: {directory}\ndescription: Example\n---\n# Readme"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(path.join(".extra"), b"hidden file").unwrap();
|
||||
fs::create_dir_all(path.join("empty-resources")).unwrap();
|
||||
SkillEntry {
|
||||
directory: directory.into(),
|
||||
path: format!("skills/{directory}"),
|
||||
enabled,
|
||||
note: Some("My skill note".into()),
|
||||
}
|
||||
}
|
||||
fn zip_entries(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, data) in entries {
|
||||
writer
|
||||
.start_file(
|
||||
*name,
|
||||
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored),
|
||||
)
|
||||
.unwrap();
|
||||
writer.write_all(data).unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
}
|
||||
fn clean_mcp(id: &str) {
|
||||
let db = open_db().unwrap();
|
||||
db.execute("DELETE FROM managed_mcp_servers WHERE id=?1", [id])
|
||||
.unwrap();
|
||||
db.execute("DELETE FROM skills_mcp_notes WHERE item_id=?1", [id])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_bundle_round_trip_preserves_files_notes_disabled_skills_and_mcp_settings() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
let source = temporary.0.join("source");
|
||||
let destination = temporary.0.join("codex");
|
||||
let directory = format!("archive-roundtrip-{}", std::process::id());
|
||||
let id = format!("archive-roundtrip-mcp-{}", std::process::id());
|
||||
clean_mcp(&id);
|
||||
let disabled = disabled_skills_dir().unwrap().join(&directory);
|
||||
let _ = fs::remove_dir_all(&disabled);
|
||||
let mut bundle = manifest();
|
||||
bundle.skills.push(skill(&source, &directory, false));
|
||||
bundle.mcp_servers.push(McpEntry { id: id.clone(), name: "Portable MCP".into(), config: json!({"command":"example-server", "args":["--safe"], "env":{"TOKEN":"synthetic-test-token"}}), enabled: true, note: Some("MCP note".into()) });
|
||||
let output = temporary.0.join("bundle.zip");
|
||||
write_archive(&output, &bundle, &[source.join("skills").join(&directory)]).unwrap();
|
||||
fs::create_dir_all(&destination).unwrap();
|
||||
fs::write(
|
||||
destination.join("config.toml"),
|
||||
"# Keep this\nmodel = \"gpt-test\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let imported =
|
||||
install_skill_archive_path_inner(codex(&destination), output.display().to_string())
|
||||
.unwrap();
|
||||
assert_eq!((imported.imported_skills, imported.imported_mcp), (1, 1));
|
||||
let skill = imported
|
||||
.state
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.id == directory)
|
||||
.unwrap();
|
||||
assert!(!skill.enabled);
|
||||
assert_eq!(skill.note.as_deref(), Some("My skill note"));
|
||||
assert_eq!(fs::read(disabled.join(".extra")).unwrap(), b"hidden file");
|
||||
assert!(disabled.join("empty-resources").is_dir());
|
||||
let mcp = imported
|
||||
.state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|mcp| mcp.id == id)
|
||||
.unwrap();
|
||||
assert_eq!(mcp.note.as_deref(), Some("MCP note"));
|
||||
assert!(mcp.enabled);
|
||||
assert!(fs::read_to_string(destination.join("config.toml"))
|
||||
.unwrap()
|
||||
.starts_with("# Keep this"));
|
||||
let second =
|
||||
install_skill_archive_path_inner(codex(&destination), output.display().to_string())
|
||||
.unwrap();
|
||||
assert_eq!((second.imported_skills, second.imported_mcp), (0, 0));
|
||||
fs::remove_dir_all(disabled).unwrap();
|
||||
clean_mcp(&id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_skill_zip_larger_than_twenty_megabytes_installs_without_size_limit() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
let payload = vec![b'x'; 21 * 1024 * 1024];
|
||||
let bytes = zip_entries(&[
|
||||
(
|
||||
"package/SKILL.md",
|
||||
b"---\nname: large-archive-fixture\n---\n",
|
||||
),
|
||||
("package/data.bin", &payload),
|
||||
]);
|
||||
assert!(bytes.len() > 20 * 1024 * 1024);
|
||||
let result = install_archive_reader(
|
||||
codex(&temporary.0.join("codex")),
|
||||
"large.zip".into(),
|
||||
Cursor::new(bytes),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.imported_skills, 1);
|
||||
assert_eq!(
|
||||
fs::metadata(
|
||||
temporary
|
||||
.0
|
||||
.join("codex/skills/large-archive-fixture/data.bin")
|
||||
)
|
||||
.unwrap()
|
||||
.len(),
|
||||
payload.len() as u64
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_bundle_is_rejected_before_any_skill_or_mcp_is_written() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
let source = temporary.0.join("source");
|
||||
let destination = temporary.0.join("codex");
|
||||
let mut bundle = manifest();
|
||||
bundle
|
||||
.skills
|
||||
.push(skill(&source, "archive-new-first", true));
|
||||
bundle
|
||||
.skills
|
||||
.push(skill(&source, "archive-existing-second", true));
|
||||
let existing = destination.join("skills/archive-existing-second");
|
||||
fs::create_dir_all(&existing).unwrap();
|
||||
fs::write(existing.join("SKILL.md"), "original").unwrap();
|
||||
assert!(import_manifest(codex(&destination), &source, bundle).is_err());
|
||||
assert!(!destination.join("skills/archive-new-first").exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(existing.join("SKILL.md")).unwrap(),
|
||||
"original"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_duplicate_and_link_zip_entries_are_rejected() {
|
||||
let temporary = test_dir();
|
||||
for name in [
|
||||
"../escape",
|
||||
"/absolute",
|
||||
"C:/drive",
|
||||
"folder\\escape",
|
||||
"CON.txt",
|
||||
] {
|
||||
let target = TempDir::new(&temporary.0).unwrap();
|
||||
assert!(
|
||||
extract(Cursor::new(zip_entries(&[(name, b"unsafe")])), &target.0).is_err(),
|
||||
"accepted {name}"
|
||||
);
|
||||
}
|
||||
let target = TempDir::new(&temporary.0).unwrap();
|
||||
assert!(extract(
|
||||
Cursor::new(zip_entries(&[("a", b"1"), ("A", b"2")])),
|
||||
&target.0
|
||||
)
|
||||
.is_err());
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
writer
|
||||
.add_symlink("link", "../outside", SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
assert!(extract(
|
||||
Cursor::new(writer.finish().unwrap().into_inner()),
|
||||
&TempDir::new(&temporary.0).unwrap().0
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_unknown_version_archives_are_rejected() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
assert!(install_archive_reader(
|
||||
codex(&temporary.0.join("codex")),
|
||||
"empty.zip".into(),
|
||||
Cursor::new(zip_entries(&[("readme.txt", b"No skills")]))
|
||||
)
|
||||
.is_err());
|
||||
let mut unknown = manifest();
|
||||
unknown.version = 99;
|
||||
let bytes = serde_json::to_vec(&unknown).unwrap();
|
||||
assert!(install_archive_reader(
|
||||
codex(&temporary.0.join("codex")),
|
||||
"future.zip".into(),
|
||||
Cursor::new(zip_entries(&[(MANIFEST, &bytes)]))
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_skill_archive_can_be_imported_with_original_state() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
let source = temporary.0.join("source");
|
||||
let destination = temporary.0.join("destination");
|
||||
let directory = format!("archive-export-{}", std::process::id());
|
||||
skill(&source, &directory, true);
|
||||
let archive = temporary.0.join("export.zip");
|
||||
let result = export_skills_mcp_archive_inner(
|
||||
codex(&source),
|
||||
"skills".into(),
|
||||
archive.display().to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(result.exported_skills >= 1);
|
||||
assert_eq!(result.exported_mcp, 0);
|
||||
let imported =
|
||||
install_skill_archive_path_inner(codex(&destination), archive.display().to_string())
|
||||
.unwrap();
|
||||
assert!(imported
|
||||
.state
|
||||
.skills
|
||||
.iter()
|
||||
.any(|skill| skill.directory == directory && skill.enabled));
|
||||
assert_eq!(
|
||||
directory_digest(&source.join("skills").join(&directory)).unwrap(),
|
||||
directory_digest(&destination.join("skills").join(&directory)).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_mcp_id_with_different_configuration_does_not_overwrite_live_config() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
let destination = temporary.0.join("codex");
|
||||
codex(&destination);
|
||||
let original = "[mcp_servers.archive_conflict]\ncommand = \"existing\"\n";
|
||||
fs::write(destination.join("config.toml"), original).unwrap();
|
||||
let mut bundle = manifest();
|
||||
bundle.mcp_servers.push(McpEntry {
|
||||
id: "archive_conflict".into(),
|
||||
name: "Conflict".into(),
|
||||
config: json!({"command":"different"}),
|
||||
enabled: true,
|
||||
note: None,
|
||||
});
|
||||
assert!(import_manifest(codex(&destination), &temporary.0, bundle).is_err());
|
||||
assert_eq!(
|
||||
fs::read_to_string(destination.join("config.toml")).unwrap(),
|
||||
original
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn export_refuses_changed_destinations_and_cleans_temporary_output() {
|
||||
let temporary = test_dir();
|
||||
let output = temporary.0.join("export.zip");
|
||||
fs::write(&output, b"before").unwrap();
|
||||
let result = write_archive_atomic(&output, &manifest(), &[], || {
|
||||
fs::write(&output, b"external-update").unwrap();
|
||||
Ok(())
|
||||
});
|
||||
assert!(result.is_err());
|
||||
assert_eq!(fs::read(&output).unwrap(), b"external-update");
|
||||
assert_eq!(fs::read_dir(&temporary.0).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_rejects_codex_data_and_non_zip_destinations() {
|
||||
let temporary = test_dir();
|
||||
let codex = temporary.0.join("codex");
|
||||
fs::create_dir_all(&codex).unwrap();
|
||||
assert!(validate_export_destination(&codex.join("backup.zip"), &codex).is_err());
|
||||
assert!(validate_export_destination(&temporary.0.join("auth.json"), &codex).is_err());
|
||||
assert!(validate_export_destination(&temporary.0.join("backup.zip"), &codex).is_ok());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let file = temporary.0.join("original.zip");
|
||||
fs::write(&file, "original").unwrap();
|
||||
let link = temporary.0.join("symlink.zip");
|
||||
std::os::unix::fs::symlink(&file, &link).unwrap();
|
||||
assert!(validate_export_destination(&link, &codex).is_err());
|
||||
assert_eq!(fs::read_to_string(file).unwrap(), "original");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,14 @@ fn toml_value_to_json(value: &toml_edit::Value) -> Value {
|
||||
if let Some(b) = value.as_bool() {
|
||||
return json!(b);
|
||||
}
|
||||
if let Some(table) = value.as_inline_table() {
|
||||
return Value::Object(
|
||||
table
|
||||
.iter()
|
||||
.map(|(key, value)| (key.to_owned(), toml_value_to_json(value)))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
if let Some(arr) = value.as_array() {
|
||||
return Value::Array(arr.iter().map(toml_value_to_json).collect());
|
||||
}
|
||||
@@ -53,7 +61,7 @@ fn toml_item_to_json(item: &Item) -> Value {
|
||||
Value::Null
|
||||
}
|
||||
|
||||
fn json_to_toml_item(value_json: &Value) -> Item {
|
||||
pub(super) fn json_to_toml_item(value_json: &Value) -> Item {
|
||||
match value_json {
|
||||
Value::String(s) => value(s.clone()),
|
||||
Value::Bool(b) => value(*b),
|
||||
@@ -143,7 +151,7 @@ pub(super) fn mcp_summary(config: &Value) -> (String, Option<String>, Option<Str
|
||||
(transport, command, url, summary)
|
||||
}
|
||||
|
||||
fn save_managed_mcp_on_connection(
|
||||
pub(super) fn save_managed_mcp_on_connection(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
name: &str,
|
||||
@@ -198,14 +206,17 @@ fn document_mcp_ids(doc: &toml_edit::DocumentMut) -> HashSet<String> {
|
||||
.map(|table| {
|
||||
table
|
||||
.iter()
|
||||
.filter(|(_, item)| item.is_table())
|
||||
.filter(|(_, item)| item.as_table_like().is_some())
|
||||
.map(|(id, _)| id.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn document_bytes(snapshot: Option<&[u8]>, doc: &toml_edit::DocumentMut) -> Option<Vec<u8>> {
|
||||
pub(super) fn document_bytes(
|
||||
snapshot: Option<&[u8]>,
|
||||
doc: &toml_edit::DocumentMut,
|
||||
) -> Option<Vec<u8>> {
|
||||
let bytes = doc.to_string().into_bytes();
|
||||
if snapshot.is_none() && bytes.is_empty() {
|
||||
None
|
||||
@@ -214,7 +225,7 @@ fn document_bytes(snapshot: Option<&[u8]>, doc: &toml_edit::DocumentMut) -> Opti
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_mcp_transaction_with_config<BeforeApply, BeforeCommit>(
|
||||
pub(super) fn commit_mcp_transaction_with_config<BeforeApply, BeforeCommit>(
|
||||
transaction: Transaction<'_>,
|
||||
codex_dir: &Path,
|
||||
before: Option<Vec<u8>>,
|
||||
@@ -285,16 +296,22 @@ pub(super) fn list_mcp_from_config(codex_dir: &Path) -> Result<Vec<ManagedMcpSer
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (id, item) in mcp_tbl.iter() {
|
||||
if !item.is_table() {
|
||||
if item.as_table_like().is_none() {
|
||||
continue;
|
||||
}
|
||||
let config = toml_item_to_json(item);
|
||||
if !is_valid_mcp_config(&config) {
|
||||
continue;
|
||||
}
|
||||
let (transport, command, url, summary) = mcp_summary(&config);
|
||||
out.push(ManagedMcpServer {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
transport,
|
||||
enabled: true,
|
||||
enabled: config
|
||||
.get("enabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true),
|
||||
source: "config.toml".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
@@ -319,51 +336,7 @@ pub(super) fn import_ccswitch_mcp_servers_for_codex(
|
||||
codex_dir: &Path,
|
||||
imported_ids: &mut HashSet<String>,
|
||||
) -> Result<usize> {
|
||||
let db = default_ccswitch_db_path()?;
|
||||
if !db.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let conn = Connection::open_with_flags(
|
||||
&db,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| {
|
||||
CodexxError::Database(format!(
|
||||
"打开 cc-switch MCP 数据库失败 {}: {e}",
|
||||
db.display()
|
||||
))
|
||||
})?;
|
||||
let mut stmt = match conn
|
||||
.prepare("SELECT id, name, server_config, enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
|
||||
.or_else(|_| {
|
||||
conn.prepare("SELECT id, name, server_config, 0 AS enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
|
||||
}) {
|
||||
Ok(stmt) => stmt,
|
||||
Err(rusqlite::Error::SqliteFailure(_, Some(message)))
|
||||
if message.to_lowercase().contains("no such table") =>
|
||||
{
|
||||
return Ok(0);
|
||||
}
|
||||
Err(e) => return Err(CodexxError::Database(e.to_string())),
|
||||
};
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, bool>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let mut candidates = Vec::new();
|
||||
for row in rows {
|
||||
let (id, name, config_text, enabled_codex) =
|
||||
row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let config: Value =
|
||||
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default()));
|
||||
candidates.push((id, name, config, enabled_codex));
|
||||
}
|
||||
let candidates = new_ccswitch_mcp_candidates(codex_dir, imported_ids)?;
|
||||
|
||||
import_ccswitch_mcp_candidates_with_hooks(
|
||||
codex_dir,
|
||||
@@ -388,7 +361,11 @@ where
|
||||
let mut staged_ids = HashSet::new();
|
||||
let candidates = candidates
|
||||
.into_iter()
|
||||
.filter(|(id, _, _, _)| !imported_ids.contains(id) && staged_ids.insert(id.clone()))
|
||||
.filter(|(id, _, config, _)| {
|
||||
is_valid_mcp_config(config)
|
||||
&& !imported_ids.contains(id)
|
||||
&& staged_ids.insert(id.clone())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if candidates.is_empty() {
|
||||
return Ok(0);
|
||||
@@ -429,9 +406,83 @@ where
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
pub(super) fn preview_ccswitch_mcp_servers_for_codex(
|
||||
codex_dir: &Path,
|
||||
) -> Result<Vec<ManagedMcpServer>> {
|
||||
// Both preview and import use the same validated records. Malformed JSON is never
|
||||
// converted to an empty stdio server.
|
||||
pub(super) fn is_valid_mcp_config(config: &Value) -> bool {
|
||||
let Some(object) = config.as_object() else {
|
||||
return false;
|
||||
};
|
||||
let nonempty = |key| {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
};
|
||||
let command = nonempty("command");
|
||||
let url = nonempty("url");
|
||||
if command == url {
|
||||
return false;
|
||||
}
|
||||
if command
|
||||
&& object.get("args").is_some_and(|args| {
|
||||
!args
|
||||
.as_array()
|
||||
.is_some_and(|values| values.iter().all(Value::is_string))
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if object.get("env").is_some_and(|env| {
|
||||
!env.as_object()
|
||||
.is_some_and(|values| values.values().all(Value::is_string))
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn mcp_configs_equal(left: &Value, right: &Value) -> bool {
|
||||
let normalize = |value: &Value| {
|
||||
let mut config = value.clone();
|
||||
if let Some(object) = config.as_object_mut() {
|
||||
// Transport is inferred from command/url by Codex; enabled is stored
|
||||
// separately from the connection settings in managed records.
|
||||
object.remove("type");
|
||||
object.remove("enabled");
|
||||
for key in ["args", "env", "http_headers", "env_http_headers"] {
|
||||
if object.get(key).is_some_and(|value| {
|
||||
value.as_array().is_some_and(Vec::is_empty)
|
||||
|| value.as_object().is_some_and(serde_json::Map::is_empty)
|
||||
}) {
|
||||
object.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
config
|
||||
};
|
||||
normalize(left) == normalize(right)
|
||||
}
|
||||
|
||||
fn normalized_ccswitch_config(config: Value, id: &str) -> Option<Value> {
|
||||
if is_valid_mcp_config(&config) {
|
||||
return Some(config);
|
||||
}
|
||||
for key in ["mcpServers", "mcp_servers"] {
|
||||
if let Some(servers) = config.get(key).and_then(Value::as_object) {
|
||||
let server = servers.get(id).or_else(|| {
|
||||
(servers.len() == 1)
|
||||
.then(|| servers.values().next())
|
||||
.flatten()
|
||||
})?;
|
||||
if is_valid_mcp_config(server) {
|
||||
return Some(server.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_ccswitch_mcp_candidates() -> Result<Vec<CcSwitchMcpCandidate>> {
|
||||
let db = default_ccswitch_db_path()?;
|
||||
if !db.exists() {
|
||||
return Ok(vec![]);
|
||||
@@ -440,29 +491,20 @@ pub(super) fn preview_ccswitch_mcp_servers_for_codex(
|
||||
&db,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| {
|
||||
CodexxError::Database(format!(
|
||||
"打开 cc-switch MCP 数据库失败 {}: {e}",
|
||||
db.display()
|
||||
))
|
||||
})?;
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
read_ccswitch_mcp_candidates_from_connection(&conn)
|
||||
}
|
||||
|
||||
fn read_ccswitch_mcp_candidates_from_connection(
|
||||
conn: &Connection,
|
||||
) -> Result<Vec<CcSwitchMcpCandidate>> {
|
||||
let mut stmt = match conn
|
||||
.prepare("SELECT id, name, server_config, enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
|
||||
.or_else(|_| {
|
||||
conn.prepare("SELECT id, name, server_config, 0 AS enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
|
||||
}) {
|
||||
.or_else(|_| conn.prepare("SELECT id, name, server_config, 0 FROM mcp_servers ORDER BY name ASC, id ASC")) {
|
||||
Ok(stmt) => stmt,
|
||||
Err(rusqlite::Error::SqliteFailure(_, Some(message)))
|
||||
if message.to_lowercase().contains("no such table") =>
|
||||
{
|
||||
return Ok(vec![]);
|
||||
}
|
||||
Err(e) => return Err(CodexxError::Database(e.to_string())),
|
||||
Err(rusqlite::Error::SqliteFailure(_, Some(message))) if message.to_lowercase().contains("no such table") => return Ok(vec![]),
|
||||
Err(error) => return Err(CodexxError::Database(error.to_string())),
|
||||
};
|
||||
let live_enabled = list_mcp_from_config(codex_dir)?
|
||||
.into_iter()
|
||||
.map(|server| server.id)
|
||||
.collect::<HashSet<_>>();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
@@ -472,30 +514,90 @@ pub(super) fn preview_ccswitch_mcp_servers_for_codex(
|
||||
row.get::<_, bool>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, name, config_text, enabled_codex) =
|
||||
row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let config: Value =
|
||||
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default()));
|
||||
let (transport, command, url, summary) = mcp_summary(&config);
|
||||
out.push(ManagedMcpServer {
|
||||
id: id.clone(),
|
||||
name,
|
||||
transport,
|
||||
enabled: enabled_codex || live_enabled.contains(&id),
|
||||
source: "cc-switch".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
});
|
||||
let (id, name, text, enabled) =
|
||||
row.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if id.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(config) = serde_json::from_str(&text)
|
||||
.ok()
|
||||
.and_then(|config| normalized_ccswitch_config(config, &id))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = if name.trim().is_empty() {
|
||||
id.clone()
|
||||
} else {
|
||||
name
|
||||
};
|
||||
out.push((id, name, config, enabled));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn new_ccswitch_mcp_candidates(
|
||||
codex_dir: &Path,
|
||||
already_seen: &HashSet<String>,
|
||||
) -> Result<Vec<CcSwitchMcpCandidate>> {
|
||||
let current = list_mcp_from_config(codex_dir)?;
|
||||
let managed = db_managed_mcp()?;
|
||||
let mut ids = already_seen.clone();
|
||||
let mut configs = Vec::new();
|
||||
for server in current {
|
||||
ids.insert(server.id);
|
||||
configs.push(server.config_json);
|
||||
}
|
||||
for (id, _, config, _) in managed {
|
||||
ids.insert(id);
|
||||
if is_valid_mcp_config(&config) {
|
||||
configs.push(config);
|
||||
}
|
||||
}
|
||||
let mut output = Vec::new();
|
||||
for candidate in read_ccswitch_mcp_candidates()? {
|
||||
if !ids.insert(candidate.0.clone())
|
||||
|| configs
|
||||
.iter()
|
||||
.any(|config| mcp_configs_equal(config, &candidate.2))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
configs.push(candidate.2.clone());
|
||||
output.push(candidate);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub(super) fn preview_ccswitch_mcp_servers_for_codex(
|
||||
codex_dir: &Path,
|
||||
) -> Result<Vec<ManagedMcpServer>> {
|
||||
let live_enabled = list_mcp_from_config(codex_dir)?
|
||||
.into_iter()
|
||||
.map(|server| server.id)
|
||||
.collect::<HashSet<_>>();
|
||||
Ok(new_ccswitch_mcp_candidates(codex_dir, &HashSet::new())?
|
||||
.into_iter()
|
||||
.map(|(id, name, config, enabled)| {
|
||||
let (transport, command, url, summary) = mcp_summary(&config);
|
||||
ManagedMcpServer {
|
||||
enabled: enabled || live_enabled.contains(&id),
|
||||
id,
|
||||
name,
|
||||
transport,
|
||||
source: "cc-switch".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_codex_mcp_inner(
|
||||
config_dir: Option<String>,
|
||||
id: String,
|
||||
@@ -529,8 +631,19 @@ where
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
let stored = managed_mcp_on_connection(&transaction, &id)?;
|
||||
if enabled {
|
||||
let (name, config, _) =
|
||||
stored.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
|
||||
let (name, mut config, _) = stored
|
||||
.or_else(|| {
|
||||
doc.get("mcp_servers")
|
||||
.and_then(Item::as_table)
|
||||
.and_then(|table| table.get(&id))
|
||||
.map(|item| (id.clone(), toml_item_to_json(item), false))
|
||||
})
|
||||
.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
|
||||
if let Some(object) = config.as_object_mut() {
|
||||
if object.contains_key("enabled") {
|
||||
object.insert("enabled".to_owned(), Value::Bool(true));
|
||||
}
|
||||
}
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(&id, json_to_toml_item(&config));
|
||||
save_managed_mcp_on_connection(&transaction, &id, &name, &config, true)?;
|
||||
} else {
|
||||
@@ -596,6 +709,75 @@ mod tests {
|
||||
.expect("remove test MCP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabling_existing_unmanaged_disabled_mcp_updates_enabled_flag() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let (directory, id) = test_case("disabled-unmanaged");
|
||||
let config = format!("[mcp_servers.\"{id}\"]\ncommand = \"fixture\"\nenabled = false\n");
|
||||
std::fs::write(directory.join("config.toml"), config).unwrap();
|
||||
let initial = list_mcp_from_config(&directory).unwrap();
|
||||
assert!(!initial[0].enabled);
|
||||
let result =
|
||||
toggle_codex_mcp_inner(Some(directory.display().to_string()), id.clone(), true)
|
||||
.unwrap();
|
||||
assert!(
|
||||
result
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == id)
|
||||
.unwrap()
|
||||
.enabled
|
||||
);
|
||||
remove_test_mcp(&id);
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccswitch_candidates_skip_empty_invalid_and_transport_only_records() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE mcp_servers(id TEXT,name TEXT,server_config TEXT,enabled_codex BOOLEAN)",
|
||||
)
|
||||
.unwrap();
|
||||
for (id, data) in [
|
||||
("broken", "invalid"),
|
||||
("empty", "{}"),
|
||||
("transport", r#"{"type":"stdio"}"#),
|
||||
("valid", r#"{"command":"npx","args":["server"]}"#),
|
||||
(
|
||||
"wrapped",
|
||||
r#"{"mcpServers":{"wrapped":{"url":"https://example.test/mcp"}}}"#,
|
||||
),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO mcp_servers VALUES(?1,?1,?2,0)",
|
||||
params![id, data],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let candidates = read_ccswitch_mcp_candidates_from_connection(&conn).unwrap();
|
||||
assert_eq!(
|
||||
candidates
|
||||
.iter()
|
||||
.map(|entry| entry.0.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["valid", "wrapped"]
|
||||
);
|
||||
assert_eq!(candidates[1].2["url"], "https://example.test/mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_environment_round_trip_retains_mcp_credentials() {
|
||||
let document: toml_edit::DocumentMut =
|
||||
"[mcp_servers.example]\ncommand = \"example\"\nenv = { TOKEN = \"synthetic\" }\n"
|
||||
.parse()
|
||||
.unwrap();
|
||||
let value = toml_item_to_json(&document["mcp_servers"]["example"]);
|
||||
assert!(is_valid_mcp_config(&value));
|
||||
assert_eq!(value["env"]["TOKEN"], "synthetic");
|
||||
assert_eq!(toml_item_to_json(&json_to_toml_item(&value)), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_updates_only_the_target_mcp_and_keeps_database_in_sync() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
mod archive;
|
||||
mod mcp;
|
||||
pub(crate) use archive::{export_skills_mcp_archive_inner, install_skill_archive_path_inner};
|
||||
mod skills;
|
||||
mod types;
|
||||
|
||||
@@ -8,7 +10,8 @@ pub(crate) use skills::{
|
||||
sort_managed_skills, toggle_codex_skill_inner,
|
||||
};
|
||||
pub(crate) use types::{
|
||||
ManagedMcpServer, SkillsMcpActionResult, SkillsMcpImportPreview, SkillsMcpState,
|
||||
ManagedMcpServer, SkillsMcpActionResult, SkillsMcpExportResult, SkillsMcpImportPreview,
|
||||
SkillsMcpState,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -17,7 +20,7 @@ pub(crate) use skills::read_skill_metadata;
|
||||
pub(crate) use types::ManagedSkill;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::file_io::{ensure_directory, io_err};
|
||||
use crate::file_io::ensure_directory;
|
||||
use crate::paths::{home_dir, normalized_path_scope};
|
||||
use crate::resolve_codex_dir;
|
||||
use crate::{now_rfc3339, open_db};
|
||||
@@ -26,10 +29,9 @@ use mcp::{
|
||||
preview_ccswitch_mcp_servers_for_codex, save_managed_mcp,
|
||||
};
|
||||
use rusqlite::params;
|
||||
use skills::{
|
||||
codex_skills_dir, copy_dir_recursive, disabled_skills_dir, sanitize_dir_name, scan_skill_dir,
|
||||
};
|
||||
use skills::{codex_skills_dir, copy_dir_recursive, disabled_skills_dir, scan_skill_dir};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
#[cfg(test)]
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -128,7 +130,7 @@ pub(crate) fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result
|
||||
let mut mcp_servers = list_mcp_from_config(&codex_dir)?;
|
||||
let enabled_ids: HashSet<String> = mcp_servers.iter().map(|s| s.id.clone()).collect();
|
||||
for (id, name, config, enabled) in db_managed_mcp()? {
|
||||
if enabled_ids.contains(&id) {
|
||||
if enabled_ids.contains(&id) || !mcp::is_valid_mcp_config(&config) {
|
||||
continue;
|
||||
}
|
||||
let (transport, command, url, summary) = mcp_summary(&config);
|
||||
@@ -221,31 +223,16 @@ pub(crate) fn import_existing_skills_mcp_inner(
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
ensure_directory(&skills_dir)?;
|
||||
let preview = preview_existing_skills_mcp_inner(config_dir.clone())?;
|
||||
let mut imported_skills = 0usize;
|
||||
let candidates = vec![
|
||||
home_dir()?.join(".agents").join("skills"),
|
||||
home_dir()?.join(".cc-switch").join("skills"),
|
||||
];
|
||||
for base in candidates {
|
||||
if !base.exists() {
|
||||
continue;
|
||||
}
|
||||
for entry in fs::read_dir(&base).map_err(|e| io_err(&base, e))? {
|
||||
let entry = entry.map_err(|e| io_err(&base, e))?;
|
||||
let src = entry.path();
|
||||
if !src.is_dir() || !src.join("SKILL.md").is_file() {
|
||||
continue;
|
||||
}
|
||||
let directory = sanitize_dir_name(&entry.file_name().to_string_lossy(), "skill");
|
||||
let dst = skills_dir.join(&directory);
|
||||
if !dst.exists() {
|
||||
copy_dir_recursive(&src, &dst)?;
|
||||
imported_skills += 1;
|
||||
}
|
||||
for skill in preview.skills {
|
||||
let destination = skills_dir.join(&skill.directory);
|
||||
if !destination.exists() {
|
||||
copy_dir_recursive(Path::new(&skill.path), &destination)?;
|
||||
imported_skills += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut imported_mcp = 0usize;
|
||||
let mut imported_mcp_ids = db_managed_mcp()?
|
||||
.into_iter()
|
||||
.map(|(id, _, _, _)| id)
|
||||
@@ -254,10 +241,11 @@ pub(crate) fn import_existing_skills_mcp_inner(
|
||||
if !imported_mcp_ids.insert(server.id.clone()) {
|
||||
continue;
|
||||
}
|
||||
// Already visible in the list: record it for deduplication without
|
||||
// presenting it as a newly imported server.
|
||||
save_managed_mcp(&server.id, &server.name, &server.config_json, true)?;
|
||||
imported_mcp += 1;
|
||||
}
|
||||
imported_mcp += import_ccswitch_mcp_servers_for_codex(&codex_dir, &mut imported_mcp_ids)?;
|
||||
let imported_mcp = import_ccswitch_mcp_servers_for_codex(&codex_dir, &mut imported_mcp_ids)?;
|
||||
let state = build_skills_mcp_state_inner(config_dir)?;
|
||||
Ok(SkillsMcpActionResult {
|
||||
imported_skills,
|
||||
@@ -272,6 +260,7 @@ pub(crate) fn preview_existing_skills_mcp_inner(
|
||||
) -> Result<SkillsMcpImportPreview> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
let disabled_dir = disabled_skills_dir()?;
|
||||
let mut warnings = Vec::new();
|
||||
let mut skills = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
@@ -293,14 +282,20 @@ pub(crate) fn preview_existing_skills_mcp_inner(
|
||||
warnings.push(e.to_string());
|
||||
}
|
||||
for skill in &mut skills[before..] {
|
||||
if skills_dir.join(&skill.directory).exists() {
|
||||
if skills_dir.join(&skill.directory).exists()
|
||||
|| disabled_dir.join(&skill.directory).exists()
|
||||
{
|
||||
skill.update_status = "已存在,将跳过".to_string();
|
||||
} else {
|
||||
skill.update_status = "可导入".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
skills.retain(|skill| skill.update_status != "已存在,将跳过");
|
||||
let mut candidate_ids = HashSet::new();
|
||||
skills.retain(|skill| {
|
||||
skill.update_status != "已存在,将跳过"
|
||||
&& candidate_ids.insert(skill.directory.to_ascii_lowercase())
|
||||
});
|
||||
|
||||
let mut config_mcp_servers = list_mcp_from_config(&codex_dir)?;
|
||||
for server in &mut config_mcp_servers {
|
||||
@@ -311,7 +306,7 @@ pub(crate) fn preview_existing_skills_mcp_inner(
|
||||
.map(|(id, _, _, _)| id)
|
||||
.collect::<HashSet<_>>();
|
||||
let mut mcp_servers = Vec::new();
|
||||
extend_unmanaged_mcp_candidates(&mut mcp_servers, &mut seen_mcp, config_mcp_servers);
|
||||
seen_mcp.extend(config_mcp_servers.into_iter().map(|server| server.id));
|
||||
extend_unmanaged_mcp_candidates(
|
||||
&mut mcp_servers,
|
||||
&mut seen_mcp,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
use super::build_skills_mcp_state_inner;
|
||||
use super::types::{CcSwitchSkillMeta, ManagedSkill, SkillsMcpActionResult, SkillsMcpState};
|
||||
use crate::ccswitch::default_ccswitch_db_path;
|
||||
use crate::constants::MAX_SKILL_ZIP_BYTES;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, read_to_string_if_exists};
|
||||
use crate::paths::app_home;
|
||||
use crate::{now_rfc3339, open_db, resolve_codex_dir};
|
||||
use chrono::Local;
|
||||
use rusqlite::{params, Connection, OpenFlags};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
@@ -101,8 +99,15 @@ fn compute_dir_hash(dir: &Path) -> Result<String> {
|
||||
.replace('\\', "/");
|
||||
hasher.update(rel.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
let bytes = fs::read(&path).map_err(|e| io_err(&path, e))?;
|
||||
hasher.update(&bytes);
|
||||
let mut file = fs::File::open(&path).map_err(|e| io_err(&path, e))?;
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let size = file.read(&mut buffer).map_err(|e| io_err(&path, e))?;
|
||||
if size == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..size]);
|
||||
}
|
||||
hasher.update(b"\0");
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
@@ -338,83 +343,7 @@ pub(crate) fn install_skill_zip_inner(
|
||||
file_name: String,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<SkillsMcpActionResult> {
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
ensure_directory(&skills_dir)?;
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
|
||||
.map_err(|e| CodexxError::Config(format!("读取 ZIP 失败: {e}")))?;
|
||||
let tmp = app_home()?
|
||||
.join("tmp")
|
||||
.join(format!("skill-zip-{}", Local::now().timestamp_millis()));
|
||||
ensure_directory(&tmp)?;
|
||||
let install_result = (|| -> Result<usize> {
|
||||
let mut total_size = 0u64;
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| CodexxError::Config(format!("读取 ZIP 条目失败: {e}")))?;
|
||||
let Some(path) = file.enclosed_name().map(|p| p.to_path_buf()) else {
|
||||
continue;
|
||||
};
|
||||
total_size += file.size();
|
||||
if total_size > MAX_SKILL_ZIP_BYTES {
|
||||
return Err(CodexxError::Config("ZIP 解压后超过 20MB".to_string()));
|
||||
}
|
||||
let out = tmp.join(path);
|
||||
if file.name().ends_with('/') {
|
||||
ensure_directory(&out)?;
|
||||
} else {
|
||||
if let Some(parent) = out.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let mut outfile = fs::File::create(&out).map_err(|e| io_err(&out, e))?;
|
||||
std::io::copy(&mut file, &mut outfile).map_err(|e| io_err(&out, e))?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut skill_dirs = Vec::new();
|
||||
fn find_skill_dirs(current: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
||||
if current.join("SKILL.md").is_file() {
|
||||
out.push(current.to_path_buf());
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(current).map_err(|e| io_err(current, e))? {
|
||||
let entry = entry.map_err(|e| io_err(current, e))?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
find_skill_dirs(&path, out)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
find_skill_dirs(&tmp, &mut skill_dirs)?;
|
||||
if skill_dirs.is_empty() {
|
||||
return Err(CodexxError::Config("ZIP 中没有找到 SKILL.md".to_string()));
|
||||
}
|
||||
let mut imported_skills = 0usize;
|
||||
for src in skill_dirs {
|
||||
let fallback = file_name.trim_end_matches(".zip");
|
||||
let dir_name = src.file_name().and_then(|v| v.to_str()).unwrap_or(fallback);
|
||||
let (skill_name, _) = read_skill_metadata(&src, dir_name);
|
||||
let dst_name = sanitize_dir_name(&skill_name, "skill");
|
||||
let dst = skills_dir.join(dst_name);
|
||||
if dst.exists() {
|
||||
fs::remove_dir_all(&dst).map_err(|e| io_err(&dst, e))?;
|
||||
}
|
||||
copy_dir_recursive(&src, &dst)?;
|
||||
imported_skills += 1;
|
||||
}
|
||||
Ok(imported_skills)
|
||||
})();
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
let imported_skills = install_result?;
|
||||
let state = build_skills_mcp_state_inner(config_dir)?;
|
||||
Ok(SkillsMcpActionResult {
|
||||
imported_skills,
|
||||
imported_mcp: 0,
|
||||
message: format!("已从 ZIP 安装 {imported_skills} 个 Skill"),
|
||||
state,
|
||||
})
|
||||
super::archive::install_archive_reader(config_dir, file_name, Cursor::new(bytes))
|
||||
}
|
||||
|
||||
fn ccswitch_skill_meta_by_directory() -> Result<HashMap<String, CcSwitchSkillMeta>> {
|
||||
|
||||
@@ -66,3 +66,11 @@ pub(super) struct CcSwitchSkillMeta {
|
||||
pub(super) repo_branch: String,
|
||||
pub(super) content_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SkillsMcpExportResult {
|
||||
pub(crate) path: String,
|
||||
pub(crate) exported_skills: usize,
|
||||
pub(crate) exported_mcp: usize,
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
])
|
||||
);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Native file selection keeps large archives out of the renderer/IPC payload.
|
||||
use crate::error::{CodexxError, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn export_name(suggested: &str, fallback: &str, extension: &str) -> String {
|
||||
let mut bytes = 0;
|
||||
let stem: String = suggested
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() && !"/\\:*?\"<>|".contains(*c))
|
||||
.take_while(|c| {
|
||||
bytes += c.len_utf8();
|
||||
bytes <= 180
|
||||
})
|
||||
.collect();
|
||||
let stem = stem.trim_matches(|c: char| c == '.' || c.is_whitespace());
|
||||
let stem = if stem.is_empty() { fallback } else { stem };
|
||||
let prefix = stem
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase();
|
||||
let reserved = matches!(prefix.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| (prefix.len() == 4
|
||||
&& (prefix.starts_with("COM") || prefix.starts_with("LPT"))
|
||||
&& prefix.as_bytes()[3].is_ascii_digit());
|
||||
format!("{}{stem}.{extension}", if reserved { "_" } else { "" })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn suggested_names_fit_filesystems_without_losing_utf8_or_using_windows_devices() {
|
||||
for title in ["中".repeat(100), "🌏".repeat(100)] {
|
||||
let name = export_name(&title, "session", "md");
|
||||
assert!(name.len() <= 184);
|
||||
assert!(name.ends_with(".md"));
|
||||
assert!(!name.contains('\u{fffd}'));
|
||||
}
|
||||
for title in ["CON", "nul", "PRN.notes", "LPT1", "COM9"] {
|
||||
assert!(export_name(title, "session", "md").starts_with('_'));
|
||||
}
|
||||
assert_eq!(export_name(" . /\\ ", "session", "zip"), "session.zip");
|
||||
}
|
||||
}
|
||||
|
||||
fn require_extension(path: &Path, extension: &str) -> Result<()> {
|
||||
if !path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(extension))
|
||||
{
|
||||
return Err(CodexxError::Config(format!(
|
||||
"请使用 .{extension} 文件名保存。"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_path(
|
||||
window: &tauri::WebviewWindow,
|
||||
name: &str,
|
||||
extension: &str,
|
||||
title: &str,
|
||||
) -> Result<Option<PathBuf>> {
|
||||
let Some(file) = rfd::AsyncFileDialog::new()
|
||||
.set_parent(window)
|
||||
.set_title(title)
|
||||
.set_file_name(name)
|
||||
.add_filter(extension, &[extension])
|
||||
.save_file()
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
require_extension(file.path(), extension)?;
|
||||
Ok(Some(file.path().to_path_buf()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn export_codex_sessions(
|
||||
window: tauri::WebviewWindow,
|
||||
config_dir: Option<String>,
|
||||
session_ids: Vec<String>,
|
||||
suggested_name: String,
|
||||
lang: Option<String>,
|
||||
) -> Result<Option<crate::sessions::SessionExportResult>> {
|
||||
if session_ids.is_empty() {
|
||||
return Err(CodexxError::Config("请先选择要导出的会话。".into()));
|
||||
}
|
||||
let extension = if session_ids.len() == 1 { "md" } else { "zip" };
|
||||
let name = export_name(&suggested_name, "Codex-sessions", extension);
|
||||
let title = if lang.as_deref() == Some("en") {
|
||||
"Export conversations"
|
||||
} else {
|
||||
"导出会话"
|
||||
};
|
||||
let Some(destination) = save_path(&window, &name, extension, title).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
crate::sessions::export_codex_sessions_inner(
|
||||
config_dir,
|
||||
session_ids,
|
||||
destination.to_string_lossy().into_owned(),
|
||||
)
|
||||
.map(Some)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("导出会话失败,请重试。".into()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn export_skills_mcp_archive(
|
||||
window: tauri::WebviewWindow,
|
||||
config_dir: Option<String>,
|
||||
kind: String,
|
||||
lang: Option<String>,
|
||||
) -> Result<Option<crate::skills_mcp::SkillsMcpExportResult>> {
|
||||
let label = match kind.as_str() {
|
||||
"mcp" => "MCP",
|
||||
"skills" => "Skills",
|
||||
_ => return Err(CodexxError::Config("不支持的导出类型。".into())),
|
||||
};
|
||||
let name = format!(
|
||||
"Codex-X-{label}-{}.zip",
|
||||
chrono::Local::now().format("%Y%m%d")
|
||||
);
|
||||
let title = if lang.as_deref() == Some("en") {
|
||||
format!("Export {label}")
|
||||
} else {
|
||||
format!("导出 {label}")
|
||||
};
|
||||
let Some(destination) = save_path(&window, &name, "zip", &title).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
crate::skills_mcp::export_skills_mcp_archive_inner(
|
||||
config_dir,
|
||||
kind,
|
||||
destination.to_string_lossy().into_owned(),
|
||||
)
|
||||
.map(Some)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("导出 ZIP 失败,请重试。".into()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn import_skills_mcp_archive(
|
||||
window: tauri::WebviewWindow,
|
||||
config_dir: Option<String>,
|
||||
lang: Option<String>,
|
||||
) -> Result<Option<crate::skills_mcp::SkillsMcpActionResult>> {
|
||||
let title = if lang.as_deref() == Some("en") {
|
||||
"Import Skills / MCP ZIP"
|
||||
} else {
|
||||
"导入 Skills / MCP ZIP"
|
||||
};
|
||||
let Some(file) = rfd::AsyncFileDialog::new()
|
||||
.set_parent(&window)
|
||||
.set_title(title)
|
||||
.add_filter("ZIP", &["zip"])
|
||||
.pick_file()
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = file.path().to_string_lossy().into_owned();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
crate::skills_mcp::install_skill_archive_path_inner(config_dir, path).map(Some)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("导入 ZIP 失败,请重试。".into()))?
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
use crate::error::{CodexxError, Result};
|
||||
use chrono::{DateTime, Duration, Local, NaiveDate, TimeZone, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::fs::{self, File, Metadata};
|
||||
@@ -13,7 +14,10 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::SystemTime;
|
||||
|
||||
const MAX_LINE_BYTES: usize = 2 * 1024 * 1024;
|
||||
// Keep ordinary rollout records on the fast slice parser. Larger records use
|
||||
// the streaming parser, which skips image/tool/message bodies without retaining
|
||||
// their contents. This is a buffer threshold, never a record-size limit.
|
||||
const MAX_BUFFERED_LINE_BYTES: usize = 64 * 1024;
|
||||
const MAX_SCAN_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
const MAX_FILES: usize = 25_000;
|
||||
const MAX_DEPTH: usize = 6;
|
||||
@@ -185,6 +189,97 @@ struct RateLimits {
|
||||
limit_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Keep only the source classification and parent ID used for grouping agents.
|
||||
/// In particular, newer source metadata may embed large, unrelated payloads.
|
||||
#[derive(Debug, Default)]
|
||||
struct UsageSource {
|
||||
is_subagent: bool,
|
||||
parent: Option<String>,
|
||||
}
|
||||
|
||||
struct UsageSourceSeed(usize);
|
||||
|
||||
impl<'de> DeserializeSeed<'de> for UsageSourceSeed {
|
||||
type Value = UsageSource;
|
||||
|
||||
fn deserialize<D: Deserializer<'de>>(
|
||||
self,
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Self::Value, D::Error> {
|
||||
deserializer.deserialize_any(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Visitor<'de> for UsageSourceSeed {
|
||||
type Value = UsageSource;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("session source metadata")
|
||||
}
|
||||
|
||||
fn visit_str<E: serde::de::Error>(self, value: &str) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource {
|
||||
is_subagent: self.0 == 0 && crate::sessions::source_kind_is_internal(value),
|
||||
parent: (self.0 == 3).then(|| value.to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
fn visit_map<M: MapAccess<'de>>(
|
||||
self,
|
||||
mut map: M,
|
||||
) -> std::result::Result<Self::Value, M::Error> {
|
||||
const PATH: [&str; 3] = ["subagent", "thread_spawn", "parent_thread_id"];
|
||||
let mut source = UsageSource::default();
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
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;
|
||||
} else {
|
||||
map.next_value::<IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
fn visit_seq<A: SeqAccess<'de>>(
|
||||
self,
|
||||
mut sequence: A,
|
||||
) -> std::result::Result<Self::Value, A::Error> {
|
||||
while sequence.next_element::<IgnoredAny>()?.is_some() {}
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
|
||||
fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
|
||||
fn visit_bool<E: serde::de::Error>(self, _: bool) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
|
||||
fn visit_i64<E: serde::de::Error>(self, _: i64) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
|
||||
fn visit_u64<E: serde::de::Error>(self, _: u64) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
|
||||
fn visit_f64<E: serde::de::Error>(self, _: f64) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for UsageSource {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||
UsageSourceSeed(0).deserialize(deserializer)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct LogPayload {
|
||||
#[serde(rename = "type", default)]
|
||||
@@ -193,7 +288,8 @@ struct LogPayload {
|
||||
#[serde(alias = "threadId")]
|
||||
thread_id: Option<String>,
|
||||
forked_from_id: Option<String>,
|
||||
source: Option<serde_json::Value>,
|
||||
source: Option<UsageSource>,
|
||||
thread_source: Option<String>,
|
||||
cwd: Option<String>,
|
||||
model: Option<String>,
|
||||
info: Option<TokenInfo>,
|
||||
@@ -226,7 +322,6 @@ struct TokenEvent {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct ParseIssues {
|
||||
malformed_lines: usize,
|
||||
oversized_lines: usize,
|
||||
invalid_usage: usize,
|
||||
incomplete_breakdown: usize,
|
||||
invalid_timestamps: usize,
|
||||
@@ -235,7 +330,6 @@ struct ParseIssues {
|
||||
impl ParseIssues {
|
||||
fn add(&mut self, other: &Self) {
|
||||
self.malformed_lines += other.malformed_lines;
|
||||
self.oversized_lines += other.oversized_lines;
|
||||
self.invalid_usage += other.invalid_usage;
|
||||
self.incomplete_breakdown += other.incomplete_breakdown;
|
||||
self.invalid_timestamps += other.invalid_timestamps;
|
||||
@@ -296,13 +390,7 @@ fn timestamp(value: Option<&str>) -> Option<DateTime<Utc>> {
|
||||
}
|
||||
|
||||
impl ParsedLog {
|
||||
fn parse_line(&mut self, bytes: &[u8]) -> std::result::Result<(), serde_json::Error> {
|
||||
if bytes.iter().all(u8::is_ascii_whitespace) {
|
||||
return Ok(());
|
||||
}
|
||||
// Serde skips unknown fields. In particular, it does not materialize a
|
||||
// response_item's message bodies or a turn_context's instructions.
|
||||
let record: LogRecord = serde_json::from_slice(bytes)?;
|
||||
fn parse_record(&mut self, record: LogRecord) {
|
||||
let time = timestamp(record.timestamp.as_deref());
|
||||
if let Some(time) = time {
|
||||
self.max_timestamp = Some(
|
||||
@@ -323,15 +411,17 @@ impl ParsedLog {
|
||||
let spawned = payload
|
||||
.source
|
||||
.as_ref()
|
||||
.and_then(|value| value.pointer("/subagent/thread_spawn/parent_thread_id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_owned);
|
||||
.and_then(|value| value.parent.clone());
|
||||
let forked = nonempty(payload.forked_from_id);
|
||||
let spawned = nonempty(spawned);
|
||||
self.is_subagent = payload
|
||||
.source
|
||||
.as_ref()
|
||||
.is_some_and(crate::sessions::source_value_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;
|
||||
@@ -360,7 +450,7 @@ impl ParsedLog {
|
||||
}
|
||||
"event_msg" if payload.kind == "token_count" => {
|
||||
let Some(info) = payload.info else {
|
||||
return Ok(());
|
||||
return;
|
||||
};
|
||||
if let Some(model) = nonempty(info.model.or(info.model_name).or(payload.model)) {
|
||||
self.model = model;
|
||||
@@ -375,7 +465,7 @@ impl ParsedLog {
|
||||
.and_then(RawCounters::counters);
|
||||
if total.is_none() && last.is_none() {
|
||||
self.issues.invalid_usage += 1;
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
let effective = if last.is_some() {
|
||||
info.last_token_usage.as_ref()
|
||||
@@ -399,7 +489,6 @@ impl ParsedLog {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,41 +573,123 @@ fn anchors(file: &mut File, offset: u64) -> std::io::Result<([u8; 32], [u8; 32])
|
||||
Ok((prefix, boundary))
|
||||
}
|
||||
|
||||
struct BoundedLine {
|
||||
bytes: Vec<u8>,
|
||||
struct ParsedLine {
|
||||
record: std::result::Result<Option<LogRecord>, serde_json::Error>,
|
||||
consumed: u64,
|
||||
terminated: bool,
|
||||
oversized: bool,
|
||||
}
|
||||
|
||||
fn read_bounded_line(reader: &mut impl BufRead) -> std::io::Result<BoundedLine> {
|
||||
let mut result = BoundedLine {
|
||||
bytes: Vec::new(),
|
||||
consumed: 0,
|
||||
terminated: false,
|
||||
oversized: false,
|
||||
};
|
||||
/// Presents exactly one physical JSONL line to Serde. Caching the next newline
|
||||
/// boundary avoids rescanning the entire buffer for each byte Serde requests.
|
||||
/// Syntax errors can be drained to the same boundary, preserving the next event.
|
||||
struct JsonLineReader<'a, R> {
|
||||
reader: &'a mut R,
|
||||
available: usize,
|
||||
ends_line: bool,
|
||||
consumed: u64,
|
||||
terminated: bool,
|
||||
non_whitespace: bool,
|
||||
}
|
||||
|
||||
impl<'a, R: BufRead> JsonLineReader<'a, R> {
|
||||
fn new(reader: &'a mut R) -> Self {
|
||||
Self {
|
||||
reader,
|
||||
available: 0,
|
||||
ends_line: false,
|
||||
consumed: 0,
|
||||
terminated: false,
|
||||
non_whitespace: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn drain(&mut self) -> std::io::Result<()> {
|
||||
std::io::copy(self, &mut std::io::sink())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: BufRead> Read for JsonLineReader<'_, R> {
|
||||
fn read(&mut self, output: &mut [u8]) -> std::io::Result<usize> {
|
||||
if self.terminated || output.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let buffer = self.reader.fill_buf()?;
|
||||
if self.available == 0 {
|
||||
let newline = buffer.iter().position(|byte| *byte == b'\n');
|
||||
self.available = newline.map_or(buffer.len(), |index| index + 1);
|
||||
self.ends_line = newline.is_some();
|
||||
}
|
||||
let count = output.len().min(self.available);
|
||||
output[..count].copy_from_slice(&buffer[..count]);
|
||||
self.non_whitespace = self.non_whitespace
|
||||
|| output[..count]
|
||||
.iter()
|
||||
.any(|byte| !byte.is_ascii_whitespace());
|
||||
self.reader.consume(count);
|
||||
self.consumed += count as u64;
|
||||
self.available -= count;
|
||||
self.terminated = self.available == 0 && self.ends_line;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_record_slice(bytes: &[u8]) -> std::result::Result<Option<LogRecord>, serde_json::Error> {
|
||||
if bytes.iter().all(u8::is_ascii_whitespace) {
|
||||
Ok(None)
|
||||
} else {
|
||||
serde_json::from_slice(bytes).map(Some)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_log_record(reader: &mut impl BufRead) -> std::io::Result<ParsedLine> {
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let buffer = reader.fill_buf()?;
|
||||
if buffer.is_empty() {
|
||||
break;
|
||||
return Ok(ParsedLine {
|
||||
record: parse_record_slice(&bytes),
|
||||
consumed: bytes.len() as u64,
|
||||
terminated: false,
|
||||
});
|
||||
}
|
||||
let newline = buffer.iter().position(|byte| *byte == b'\n');
|
||||
let count = newline.map_or(buffer.len(), |index| index + 1);
|
||||
result.consumed += count as u64;
|
||||
if !result.oversized && result.bytes.len() + count <= MAX_LINE_BYTES {
|
||||
result.bytes.extend_from_slice(&buffer[..count]);
|
||||
} else {
|
||||
result.oversized = true;
|
||||
result.bytes.clear();
|
||||
if bytes.len() + count > MAX_BUFFERED_LINE_BYTES {
|
||||
// Derived struct deserializers use IgnoredAny for unknown fields:
|
||||
// Serde validates/skips large strings and arrays directly from the
|
||||
// reader, rather than constructing a String, Value, or whole line.
|
||||
let mut tail = JsonLineReader::new(reader);
|
||||
let prefix_non_whitespace = bytes.iter().any(|byte| !byte.is_ascii_whitespace());
|
||||
let record = serde_json::from_reader(bytes.as_slice().chain(&mut tail)).map(Some);
|
||||
if let Err(error) = &record {
|
||||
if let Some(kind) = error.io_error_kind() {
|
||||
return Err(std::io::Error::new(kind, "会话文件在读取期间发生错误"));
|
||||
}
|
||||
}
|
||||
// A bad record must not consume the next JSONL line, nor prevent it
|
||||
// from contributing usage. Also count all bytes against scan budget.
|
||||
tail.drain()?;
|
||||
return Ok(ParsedLine {
|
||||
record: if prefix_non_whitespace || tail.non_whitespace {
|
||||
record
|
||||
} else {
|
||||
Ok(None)
|
||||
},
|
||||
consumed: bytes.len() as u64 + tail.consumed,
|
||||
terminated: tail.terminated,
|
||||
});
|
||||
}
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
reader.consume(count);
|
||||
if newline.is_some() {
|
||||
result.terminated = true;
|
||||
break;
|
||||
return Ok(ParsedLine {
|
||||
record: parse_record_slice(&bytes),
|
||||
consumed: bytes.len() as u64,
|
||||
terminated: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn parse_file(
|
||||
@@ -574,23 +745,19 @@ fn parse_file(
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
let line = read_bounded_line(&mut reader).map_err(|error| io_error(path, error))?;
|
||||
let line = read_log_record(&mut reader).map_err(|error| io_error(path, error))?;
|
||||
if line.consumed == 0 {
|
||||
break;
|
||||
}
|
||||
*budget = budget.saturating_sub(line.consumed);
|
||||
if line.oversized {
|
||||
if !line.terminated {
|
||||
match line.record {
|
||||
Ok(Some(record)) => parsed.parse_record(record),
|
||||
Ok(None) => {}
|
||||
Err(error) if !line.terminated && error.is_eof() => {
|
||||
parsed.pending_line = true;
|
||||
break;
|
||||
}
|
||||
parsed.issues.oversized_lines += 1;
|
||||
} else if let Err(error) = parsed.parse_line(&line.bytes) {
|
||||
if !line.terminated && error.is_eof() {
|
||||
parsed.pending_line = true;
|
||||
break;
|
||||
}
|
||||
parsed.issues.malformed_lines += 1;
|
||||
Err(_) => parsed.issues.malformed_lines += 1,
|
||||
}
|
||||
offset += line.consumed;
|
||||
}
|
||||
@@ -698,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();
|
||||
@@ -748,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();
|
||||
@@ -763,7 +962,6 @@ fn combine_files(
|
||||
}
|
||||
for (count, message) in [
|
||||
(issues.malformed_lines, "行日志无法解析"),
|
||||
(issues.oversized_lines, "行日志超过 2 MiB,已跳过"),
|
||||
(issues.invalid_usage, "条用量记录缺少有效的输入或输出计数"),
|
||||
(issues.invalid_timestamps, "条用量记录缺少有效时间"),
|
||||
(without_identity, "个会话文件缺少会话标识,已跳过"),
|
||||
@@ -946,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()
|
||||
@@ -958,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);
|
||||
@@ -1049,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();
|
||||
@@ -1886,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);
|
||||
}
|
||||
|
||||
@@ -1931,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]
|
||||
@@ -2235,7 +2528,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_reader_skips_large_lines_and_reports_bad_or_old_usage() {
|
||||
fn streaming_reader_recovers_after_large_invalid_lines_and_reports_bad_or_old_usage() {
|
||||
let mut fixture = Fixture::new();
|
||||
let path = fixture.write(
|
||||
"sessions/one.jsonl",
|
||||
@@ -2256,7 +2549,7 @@ mod tests {
|
||||
);
|
||||
let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap();
|
||||
file.write_all(b"{not json}\n").unwrap();
|
||||
file.write_all(&vec![b'x'; MAX_LINE_BYTES + 10]).unwrap();
|
||||
file.write_all(&vec![b'x'; 2 * 1024 * 1024 + 10]).unwrap();
|
||||
file.write_all(b"\n").unwrap();
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 110);
|
||||
@@ -2264,7 +2557,7 @@ mod tests {
|
||||
.coverage
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("2 MiB")));
|
||||
.any(|warning| warning.contains("2 行日志无法解析")));
|
||||
assert!(stats
|
||||
.coverage
|
||||
.warnings
|
||||
@@ -2282,6 +2575,158 @@ mod tests {
|
||||
.any(|warning| warning.contains("有效的输入或输出")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_tool_image_and_token_records_keep_all_usage_without_coverage_warnings() {
|
||||
let mut fixture = Fixture::new();
|
||||
let huge = "x".repeat(2 * 1024 * 1024 + 100);
|
||||
// Field ordering is deliberate: counters/metadata can follow the large
|
||||
// value, so looking only at a record's prefix cannot recover usage.
|
||||
let text = format!(
|
||||
"{{\"payload\":{{\"instructions\":\"{huge}\",\"id\":\"one\"}},\"type\":\"session_meta\",\"timestamp\":\"2026-09-08T01:00:00Z\"}}\n\
|
||||
{{\"payload\":{{\"input_image\":{{\"image_url\":\"data:image/png;base64,{huge}\"}},\"output\":\"{huge}\"}},\"type\":\"response_item\"}}\n\
|
||||
{{\"payload\":{{\"instructions\":\"{huge}\",\"model\":\"gpt-large-fixture\"}},\"type\":\"turn_context\"}}\n\
|
||||
{{\"payload\":{{\"info\":{{\"ignored\":\"{huge}\",\"total_token_usage\":{{\"input_tokens\":100,\"cached_input_tokens\":20,\"output_tokens\":10,\"reasoning_output_tokens\":5}}}},\"type\":\"token_count\"}},\"type\":\"event_msg\",\"timestamp\":\"2026-09-08T01:00:01Z\"}}\n"
|
||||
);
|
||||
let path = fixture.dir.join("sessions/one.jsonl");
|
||||
fs::write(&path, text).unwrap();
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 110);
|
||||
assert_eq!(stats.totals.cached_input_tokens, 20);
|
||||
assert_eq!(stats.totals.reasoning_tokens, 5);
|
||||
assert_eq!(stats.sessions[0].model, "gpt-large-fixture");
|
||||
assert!(stats.coverage.warnings.is_empty(), "{:?}", stats.coverage);
|
||||
// Appended events still resume at the exact boundary after large lines.
|
||||
fixture.append(&path, &[total("2026-09-08T01:00:02Z", 150, 15)]);
|
||||
assert_eq!(fixture.stats("all", None).totals.total_tokens, 165);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_source_metadata_retains_agent_parent_without_materializing_extensions() {
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.write(
|
||||
"sessions/root.jsonl",
|
||||
&[
|
||||
meta("root", "2026-09-08T01:00:00Z", None),
|
||||
context("gpt-5.4"),
|
||||
total("2026-09-08T01:00:01Z", 100, 10),
|
||||
],
|
||||
);
|
||||
let mut child = spawn_meta("child", "root", "2026-09-08T01:00:02Z");
|
||||
child["payload"]["source"]["subagent"]["thread_spawn"]["instructions"] =
|
||||
json!("private extension ".repeat(140_000));
|
||||
fixture.write(
|
||||
"sessions/child.jsonl",
|
||||
&[
|
||||
child,
|
||||
context("gpt-5.4"),
|
||||
total("2026-09-08T01:00:03Z", 50, 5),
|
||||
],
|
||||
);
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 165);
|
||||
assert_eq!(stats.sessions.len(), 1);
|
||||
assert_eq!(stats.sessions[0].id, "root");
|
||||
assert!(stats.coverage.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_broken_record_does_not_count_partial_tokens_or_consume_next_line() {
|
||||
let mut fixture = Fixture::new();
|
||||
let path = fixture.write(
|
||||
"sessions/one.jsonl",
|
||||
&[
|
||||
meta("one", "2026-09-08T01:00:00Z", None),
|
||||
context("gpt-5.4"),
|
||||
],
|
||||
);
|
||||
let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap();
|
||||
let mut bad = total("2026-09-08T01:00:01Z", 1000, 100);
|
||||
bad["large_ignored"] = json!("x".repeat(2 * 1024 * 1024 + 100));
|
||||
writeln!(file, "{bad} trailing garbage").unwrap();
|
||||
fixture.append(&path, &[total("2026-09-08T01:00:02Z", 100, 10)]);
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 110);
|
||||
assert_eq!(stats.coverage.warnings.len(), 1);
|
||||
assert!(stats.coverage.warnings[0].contains("1 行日志无法解析"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partially_written_large_record_retries_then_counts_once_when_completed() {
|
||||
let mut fixture = Fixture::new();
|
||||
let path = fixture.write(
|
||||
"sessions/one.jsonl",
|
||||
&[
|
||||
meta("one", "2026-09-08T01:00:00Z", None),
|
||||
context("gpt-5.4"),
|
||||
total("2026-09-08T01:00:01Z", 100, 10),
|
||||
],
|
||||
);
|
||||
let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap();
|
||||
write!(
|
||||
file,
|
||||
"{{\"ignored\":\"{}",
|
||||
"x".repeat(2 * 1024 * 1024 + 100)
|
||||
)
|
||||
.unwrap();
|
||||
let pending = fixture.stats("all", None);
|
||||
assert_eq!(pending.totals.total_tokens, 110);
|
||||
assert_eq!(pending.coverage.warnings.len(), 1);
|
||||
assert!(pending.coverage.warnings[0].contains("仍在写入"));
|
||||
let event = total("2026-09-08T01:00:02Z", 200, 20).to_string();
|
||||
// A complete final record is valid even before its newline is written.
|
||||
write!(file, "\",{}", &event[1..]).unwrap();
|
||||
let complete = fixture.stats("all", None);
|
||||
assert_eq!(complete.totals.total_tokens, 220);
|
||||
assert!(complete.coverage.warnings.is_empty());
|
||||
writeln!(file).unwrap();
|
||||
fixture.append(&path, &[total("2026-09-08T01:00:03Z", 300, 30)]);
|
||||
assert_eq!(fixture.stats("all", None).totals.total_tokens, 330);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_line_boundaries_accept_escaped_content_and_large_blank_lines() {
|
||||
let huge = "\\\"中文\n".repeat(300_000);
|
||||
let mut event = total("2026-09-08T01:00:01Z", 100, 10);
|
||||
event["unknown"] = json!(huge);
|
||||
let text = format!("{}\n{event}\n", " ".repeat(2 * 1024 * 1024 + 10));
|
||||
let mut reader = BufReader::with_capacity(127, text.as_bytes());
|
||||
let blank = read_log_record(&mut reader).unwrap();
|
||||
assert!(blank.record.unwrap().is_none());
|
||||
assert!(blank.terminated);
|
||||
let record = read_log_record(&mut reader).unwrap();
|
||||
assert_eq!(record.record.unwrap().unwrap().payload.kind, "token_count");
|
||||
assert!(record.terminated);
|
||||
assert_eq!(blank.consumed + record.consumed, text.len() as u64);
|
||||
assert_eq!(read_log_record(&mut reader).unwrap().consumed, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_budget_resumes_after_large_record_without_skipping_its_tokens() {
|
||||
let fixture = Fixture::new();
|
||||
let mut large = total("2026-09-08T01:00:01Z", 100, 10);
|
||||
large["unknown"] = json!("x".repeat(2 * 1024 * 1024 + 10));
|
||||
let path = fixture.write(
|
||||
"sessions/one.jsonl",
|
||||
&[
|
||||
meta("one", "2026-09-08T01:00:00Z", None),
|
||||
large,
|
||||
total("2026-09-08T01:00:02Z", 150, 15),
|
||||
],
|
||||
);
|
||||
// Scan budgets stop between records, even if one large record crosses
|
||||
// the remaining budget. Its completed token event must be retained.
|
||||
let mut budget = MAX_BUFFERED_LINE_BYTES as u64;
|
||||
let partial = parse_file(&path, None, false, &mut budget).unwrap();
|
||||
assert!(!partial.complete);
|
||||
assert_eq!(partial.parsed.events.len(), 1);
|
||||
assert_eq!(budget, 0);
|
||||
let mut budget = MAX_SCAN_BYTES;
|
||||
let complete = parse_file(&path, Some(partial), false, &mut budget).unwrap();
|
||||
assert!(complete.complete);
|
||||
assert_eq!(complete.parsed.events.len(), 2);
|
||||
assert!(complete.parsed.issues.malformed_lines == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_timestamp_is_not_fabricated_from_file_modified_time() {
|
||||
let mut fixture = Fixture::new();
|
||||
|
||||
@@ -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.17",
|
||||
"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());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
@@ -9,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";
|
||||
|
||||
@@ -101,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
|
||||
@@ -118,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: "更新没有完成",
|
||||
@@ -134,7 +141,16 @@ export function UpdateDialog({
|
||||
close: "关闭",
|
||||
updateNow: "立即更新",
|
||||
downloading: "正在下载",
|
||||
installing: "正在安装",
|
||||
verifying: "验证安装包",
|
||||
preparing: "准备退出",
|
||||
installing: "启动安装程序",
|
||||
handedOff: "等待安装程序",
|
||||
slowTitle: "这一步比平时慢一些",
|
||||
slowDescription: phase === "downloading"
|
||||
? "暂时没有收到新的下载数据,正在等待网络恢复。请勿重复点击更新。"
|
||||
: "仍在等待当前步骤完成。请查看是否有系统授权或安装窗口等待操作,不要重复启动安装。",
|
||||
detailLabel: "失败原因",
|
||||
logLabel: "安装日志",
|
||||
restart: "重新启动",
|
||||
retry: "重试",
|
||||
downloadPage: "打开下载页",
|
||||
@@ -149,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",
|
||||
@@ -165,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();
|
||||
@@ -238,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>;
|
||||
@@ -271,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
|
||||
@@ -288,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>
|
||||
@@ -313,8 +348,11 @@ export function UpdateDialog({
|
||||
export type StartupWizardDialogProps = {
|
||||
open: boolean;
|
||||
closing: boolean;
|
||||
mode?: "startup" | "manual";
|
||||
lang: Lang;
|
||||
diagnostics: StartupDiagnostics | null;
|
||||
diagnosticsError?: string;
|
||||
configHealthPanel?: ReactNode;
|
||||
configDir: string;
|
||||
loading: boolean;
|
||||
onConfigDirChange: (value: string) => void;
|
||||
@@ -327,8 +365,11 @@ export type StartupWizardDialogProps = {
|
||||
export function StartupWizardDialog({
|
||||
open,
|
||||
closing,
|
||||
mode = "startup",
|
||||
lang,
|
||||
diagnostics,
|
||||
diagnosticsError,
|
||||
configHealthPanel,
|
||||
configDir,
|
||||
loading,
|
||||
onConfigDirChange,
|
||||
@@ -338,48 +379,76 @@ export function StartupWizardDialog({
|
||||
onEnter,
|
||||
}: StartupWizardDialogProps) {
|
||||
const isChinese = lang === "zh";
|
||||
if (!diagnostics) return null;
|
||||
const isManual = mode === "manual";
|
||||
const recheckButton = (
|
||||
<Button
|
||||
variant={isManual ? "primary" : "secondary"}
|
||||
icon={<RefreshCw size={16} className={loading ? "spin" : undefined} />}
|
||||
onClick={onRecheck}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (isChinese ? "正在检查" : "Checking") : (isChinese ? "重新检查" : "Recheck")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
open={open}
|
||||
onClose={onSkip}
|
||||
size="lg"
|
||||
title={isChinese ? "首次启动向导" : "First-run wizard"}
|
||||
description={diagnostics.summary}
|
||||
showCloseButton={false}
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
title={isChinese ? "环境与配置检查" : "Environment & configuration check"}
|
||||
description={isManual
|
||||
? (isChinese ? "查看环境与配置状态,发现问题后可选择修复。" : "Review your environment and configuration, and choose whether to repair any issues.")
|
||||
: (isChinese ? "首次使用前,检查 Codex 环境与配置是否就绪。" : "Before you get started, check whether your Codex environment and configuration are ready.")}
|
||||
showCloseButton={isManual}
|
||||
closeLabel={isChinese ? "关闭" : "Close"}
|
||||
closeOnBackdrop={isManual}
|
||||
closeOnEscape={isManual}
|
||||
className={closing ? "cx-startup-dialog cx-startup-dialog--closing" : "cx-startup-dialog"}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="ghost" onClick={onSkip}>{isChinese ? "跳过" : "Skip"}</Button>
|
||||
<Button variant="secondary" icon={<Settings size={16} />} onClick={onOpenSettings}>{isChinese ? "去设置" : "Settings"}</Button>
|
||||
<Button icon={<CheckCircle2 size={16} />} onClick={onEnter}>{isChinese ? "进入 Codex-X" : "Enter Codex-X"}</Button>
|
||||
</>
|
||||
isManual ? (
|
||||
<>
|
||||
<Button variant="secondary" onClick={onSkip}>{isChinese ? "关闭" : "Close"}</Button>
|
||||
{recheckButton}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" onClick={onSkip}>{isChinese ? "跳过" : "Skip"}</Button>
|
||||
<Button variant="secondary" icon={<Settings size={16} />} onClick={onOpenSettings}>{isChinese ? "去设置" : "Settings"}</Button>
|
||||
<Button icon={<CheckCircle2 size={16} />} onClick={onEnter}>{isChinese ? "进入 Codex-X" : "Enter Codex-X"}</Button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
>
|
||||
<div className="cx-startup-path-control">
|
||||
<div className={`cx-startup-path-control${isManual ? " cx-startup-path-control--manual" : ""}`}>
|
||||
<label htmlFor="cx-startup-codex-home">CODEX_HOME</label>
|
||||
<input
|
||||
id="cx-startup-codex-home"
|
||||
value={configDir || diagnostics.codexDir}
|
||||
value={configDir}
|
||||
onChange={(event) => onConfigDirChange(event.target.value)}
|
||||
placeholder="~/.codex"
|
||||
disabled={loading}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={<RefreshCw size={16} className={loading ? "spin" : undefined} />}
|
||||
onClick={onRecheck}
|
||||
disabled={loading}
|
||||
>
|
||||
{isChinese ? "重新检测" : "Recheck"}
|
||||
</Button>
|
||||
{!isManual && recheckButton}
|
||||
</div>
|
||||
|
||||
<div className="cx-startup-checks">
|
||||
{diagnosticsError && <div className="cx-startup-diagnostics-notice cx-startup-diagnostics-notice--error" role="alert">
|
||||
<AlertCircle size={17} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{isChinese ? "环境检查未完成" : "Environment check did not finish"}</strong>
|
||||
<p>{diagnosticsError}</p>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{!diagnostics && !diagnosticsError && <div className="cx-startup-diagnostics-notice" role="status" aria-live="polite">
|
||||
{loading ? <Loader2 className="spin" size={17} aria-hidden="true" /> : <RefreshCw size={17} aria-hidden="true" />}
|
||||
<p>{loading
|
||||
? (isChinese ? "正在检查 Codex 环境…" : "Checking your Codex environment…")
|
||||
: (isChinese ? "点击重新检查,查看当前环境状态。" : "Choose Recheck to see the current environment status.")}</p>
|
||||
</div>}
|
||||
|
||||
{diagnostics && <div className="cx-startup-checks">
|
||||
{diagnostics.items.map((item) => {
|
||||
const isOk = item.status === "ok";
|
||||
const isManual = item.status === "manual";
|
||||
@@ -403,7 +472,9 @@ export function StartupWizardDialog({
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{configHealthPanel && <div className="cx-startup-config-health">{configHealthPanel}</div>}
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useId } from "react";
|
||||
import { AlertTriangle, CheckCircle2, FileSearch, FolderOpen, Loader2, RefreshCw, Wrench } from "lucide-react";
|
||||
import type { ConfigHealthReport } from "../configHealthTypes";
|
||||
import { Button } from "./ui";
|
||||
import "../styles/config-health.css";
|
||||
|
||||
export type ConfigHealthPanelProps = {
|
||||
lang: "zh" | "en";
|
||||
report: ConfigHealthReport | null;
|
||||
checking: boolean;
|
||||
repairing: boolean;
|
||||
error: string;
|
||||
onCheck: () => void;
|
||||
onRepair: () => void;
|
||||
onOpenConfig?: () => void;
|
||||
};
|
||||
|
||||
export function ConfigHealthStatus({ lang, report, checking, repairing, error }: Pick<ConfigHealthPanelProps, "lang" | "report" | "checking" | "repairing" | "error">) {
|
||||
const zh = lang === "zh";
|
||||
const busy = checking || repairing;
|
||||
const hasIssues = report?.status === "issues";
|
||||
const healthy = report?.status === "healthy" && !error;
|
||||
const tone = error || hasIssues || report?.status === "unavailable" ? "warning" : healthy ? "success" : "neutral";
|
||||
const status = repairing ? (zh ? "正在修复" : "Repairing")
|
||||
: checking ? (zh ? "正在检查" : "Checking")
|
||||
: error ? (zh ? "操作未完成" : "Action incomplete")
|
||||
: healthy ? (zh ? "配置正常" : "Looks good")
|
||||
: hasIssues ? (zh ? "发现问题" : "Needs attention")
|
||||
: report?.status === "missing" ? (zh ? "尚无配置" : "No configuration")
|
||||
: report?.status === "unavailable" ? (zh ? "暂时无法检查" : "Unavailable")
|
||||
: (zh ? "未检查" : "Not checked");
|
||||
const StatusIcon = busy ? Loader2 : tone === "warning" ? AlertTriangle : healthy ? CheckCircle2 : FileSearch;
|
||||
return <span className={`cx-config-health-status cx-config-health-status--${tone}`} role="status" aria-live="polite">
|
||||
<StatusIcon size={14} aria-hidden="true" className={busy ? "cx-config-health-spinner" : undefined} />
|
||||
{status}
|
||||
</span>;
|
||||
}
|
||||
|
||||
export function ConfigHealthPanel({ lang, report, checking, repairing, error, onCheck, onRepair, onOpenConfig }: ConfigHealthPanelProps) {
|
||||
const titleId = useId();
|
||||
const zh = lang === "zh";
|
||||
const busy = checking || repairing;
|
||||
const hasIssues = report?.status === "issues";
|
||||
const healthy = report?.status === "healthy" && !error;
|
||||
const manualIssues = hasIssues && report.issues.some((issue) => !issue.repairable);
|
||||
|
||||
return <section className="cx-config-health" aria-labelledby={titleId}>
|
||||
<div className="cx-config-health-heading">
|
||||
<span className="cx-config-health-icon" aria-hidden="true"><FileSearch size={19} /></span>
|
||||
<div className="cx-config-health-heading-copy">
|
||||
<h3 id={titleId}>{zh ? "配置检查与修复" : "Configuration check & repair"}</h3>
|
||||
<p>{zh ? "检查影响 Codex 启动和供应商使用的配置问题,由你决定是否修复。" : "Check for configuration issues that affect Codex and your providers. You choose whether to repair them."}</p>
|
||||
</div>
|
||||
<ConfigHealthStatus lang={lang} report={report} checking={checking} repairing={repairing} error={error} />
|
||||
</div>
|
||||
|
||||
{error && <p className="cx-config-health-error" role="alert">{error}</p>}
|
||||
|
||||
{hasIssues && <div className="cx-config-health-details">
|
||||
<ul className="cx-config-health-issues">
|
||||
{report.issues.map((issue, index) => <li key={`${issue.code}-${index}`}>
|
||||
<AlertTriangle size={15} aria-hidden="true" />
|
||||
<div><strong>{issue.title}</strong><p>{issue.description}</p></div>
|
||||
</li>)}
|
||||
</ul>
|
||||
{report.canRepair && report.repairSummary.length > 0 && <div className="cx-config-health-plan">
|
||||
<strong>{zh ? "点击修复后,将进行以下调整:" : "Repair will make these changes:"}</strong>
|
||||
<ul>{report.repairSummary.map((summary, index) => <li key={index}>{summary}</li>)}</ul>
|
||||
</div>}
|
||||
{manualIssues && <p className="cx-config-health-note">{zh ? "部分问题需要手动检查。为保留你的配置,软件不会猜测或覆盖无法确认的内容。" : "Some issues need a manual check. Settings that cannot be determined safely will be left for you to review."}</p>}
|
||||
</div>}
|
||||
|
||||
{report?.status === "missing" && <p className="cx-config-health-note">{zh ? "还没有找到配置文件。完成 Codex 登录或启用供应商后,可以再次检查。" : "No configuration file was found. Check again after signing in to Codex or enabling a provider."}</p>}
|
||||
{report?.status === "unavailable" && !error && <p className="cx-config-health-note">{report.issues[0]?.description || (zh ? "暂时无法读取配置,请稍后重试或打开配置查看。" : "The configuration could not be read. Try again later or open it to review.")}</p>}
|
||||
|
||||
<div className="cx-config-health-footer">
|
||||
<p className="cx-config-health-note">{healthy
|
||||
? (zh ? "暂未发现需要修复的配置问题。" : "No configuration issues were found.")
|
||||
: (zh ? "检查不会修改配置。点击修复后,会先自动备份。" : "Checking does not change your settings. Choosing repair creates a backup first.")}</p>
|
||||
<div className="cx-config-health-actions">
|
||||
{onOpenConfig && (hasIssues || report?.status === "unavailable") && <Button variant="ghost" size="sm" icon={<FolderOpen size={15} />} disabled={busy} onClick={onOpenConfig}>{zh ? "查看配置" : "View configuration"}</Button>}
|
||||
<Button variant="secondary" size="sm" icon={checking ? <Loader2 size={15} className="cx-config-health-spinner" /> : <RefreshCw size={15} />} disabled={busy} onClick={onCheck}>{checking ? (zh ? "检查中" : "Checking") : (zh ? "检查配置" : "Check configuration")}</Button>
|
||||
{report?.canRepair && hasIssues && <Button size="sm" icon={repairing ? <Loader2 size={15} className="cx-config-health-spinner" /> : <Wrench size={15} />} disabled={busy} onClick={onRepair}>{repairing ? (zh ? "修复中" : "Repairing") : (zh ? "修复配置" : "Repair configuration")}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { AlertTriangle, ArrowRight, Loader2, Wrench, X } from "lucide-react";
|
||||
import type { ConfigHealthReport } from "../configHealthTypes";
|
||||
import { Button } from "./ui";
|
||||
import "../styles/config-health.css";
|
||||
|
||||
export type ConfigHealthToastProps = {
|
||||
lang: "zh" | "en";
|
||||
report: ConfigHealthReport;
|
||||
repairing: boolean;
|
||||
onRepair: () => void;
|
||||
onDismiss: () => void;
|
||||
onOpenSettings: () => void;
|
||||
};
|
||||
|
||||
/** The caller owns notification lifetime; CSS never dismisses this toast. */
|
||||
export function ConfigHealthToast({ lang, report, repairing, onRepair, onDismiss, onOpenSettings }: ConfigHealthToastProps) {
|
||||
const zh = lang === "zh";
|
||||
return <aside className="cx-config-health-toast" aria-label={zh ? "配置检查提醒" : "Configuration check notification"}>
|
||||
<span className="cx-config-health-toast-icon" aria-hidden="true"><AlertTriangle size={20} /></span>
|
||||
<div className="cx-config-health-toast-content">
|
||||
<div role="status" aria-live="polite" aria-atomic="true">
|
||||
<strong>{repairing ? (zh ? "正在修复 Codex 配置" : "Repairing Codex configuration") : (zh ? "检测到 Codex 配置问题" : "Codex configuration needs attention")}</strong>
|
||||
<p>{(report.canRepair ? report.issues[0]?.title : report.issues[0]?.description) || (zh ? "部分配置可能影响 Codex 正常使用。" : "Some settings may prevent Codex from working correctly.")}</p>
|
||||
{report.canRepair && report.repairSummary[0] && <p className="cx-config-health-toast-plan">{report.repairSummary[0]}</p>}
|
||||
</div>
|
||||
<div className="cx-config-health-toast-actions">
|
||||
{report.canRepair && <Button size="sm" icon={repairing ? <Loader2 size={15} className="cx-config-health-spinner" /> : <Wrench size={15} />} disabled={repairing} onClick={onRepair}>{repairing ? (zh ? "修复中" : "Repairing") : (zh ? "修复配置" : "Repair configuration")}</Button>}
|
||||
<Button variant={report.canRepair ? "ghost" : "primary"} size="sm" icon={<ArrowRight size={15} />} iconPosition="end" disabled={repairing} onClick={onOpenSettings}>{zh ? "查看设置" : "View settings"}</Button>
|
||||
</div>
|
||||
<p className="cx-config-health-toast-hint">{zh ? "也可稍后前往「设置 → 通用设置 → 环境与配置检查」处理。" : "You can also use Settings → General → Environment & configuration check later."}</p>
|
||||
</div>
|
||||
<button type="button" className="cx-config-health-toast-close" disabled={repairing} onClick={onDismiss} aria-label={zh ? "关闭配置检查提醒" : "Dismiss configuration notification"} title={zh ? "关闭提醒" : "Dismiss"}><X size={17} aria-hidden="true" /></button>
|
||||
</aside>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { ConfigHealthReport } from "./configHealthTypes";
|
||||
|
||||
type Options = {
|
||||
read: () => Promise<ConfigHealthReport>;
|
||||
onReport: (report: ConfigHealthReport) => void;
|
||||
onProblem: (report: ConfigHealthReport) => void;
|
||||
onRecovered?: (report: ConfigHealthReport) => void;
|
||||
canRead: () => boolean;
|
||||
canNotify: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
revision: () => number;
|
||||
intervalMs?: number;
|
||||
settleMs?: number;
|
||||
now?: () => number;
|
||||
schedule?: (callback: () => void, delay: number) => () => void;
|
||||
};
|
||||
|
||||
// Confirm a broken file twice before notifying: an editor may briefly truncate
|
||||
// or replace it. Reads are single-flight and never change shared loading state.
|
||||
export function createConfigHealthMonitor(options: Options) {
|
||||
const schedule = options.schedule ?? ((callback, delay) => {
|
||||
const timer = setTimeout(callback, delay);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
const now = options.now ?? Date.now;
|
||||
const interval = options.intervalMs ?? 20_000;
|
||||
const settle = options.settleMs ?? 1_000;
|
||||
let stopped = false;
|
||||
let inFlight = false;
|
||||
let wakePending = false;
|
||||
let cancel: (() => void) | undefined;
|
||||
let candidate: { fingerprint: string; revision: number; at: number } | null = null;
|
||||
let recovery: { fingerprint: string; revision: number; at: number } | null = null;
|
||||
|
||||
function next(delay = interval) {
|
||||
cancel?.();
|
||||
cancel = undefined;
|
||||
if (!stopped && options.isVisible()) cancel = schedule(wake, delay);
|
||||
}
|
||||
async function read() {
|
||||
if (stopped || !options.isVisible()) return;
|
||||
if (!options.canRead()) { next(); return; }
|
||||
inFlight = true;
|
||||
const revision = options.revision();
|
||||
let delay = interval;
|
||||
try {
|
||||
const report = await options.read();
|
||||
if (stopped || wakePending || revision !== options.revision() || !options.canRead() || !options.isVisible()) return;
|
||||
options.onReport(report);
|
||||
if (report.status === "healthy") {
|
||||
if (recovery?.fingerprint === report.fingerprint && recovery.revision === revision && now() - recovery.at >= settle) {
|
||||
options.onRecovered?.(report);
|
||||
} else if (!recovery || recovery.fingerprint !== report.fingerprint || recovery.revision !== revision) {
|
||||
recovery = { fingerprint: report.fingerprint, revision, at: now() };
|
||||
}
|
||||
} else recovery = null;
|
||||
if (report.status !== "issues" || !report.issues.length) {
|
||||
candidate = null;
|
||||
return;
|
||||
}
|
||||
if (!candidate || candidate.fingerprint !== report.fingerprint || candidate.revision !== revision) {
|
||||
candidate = { fingerprint: report.fingerprint, revision, at: now() };
|
||||
delay = settle;
|
||||
} else if (now() - candidate.at < settle) {
|
||||
delay = settle - (now() - candidate.at);
|
||||
} else if (options.canNotify()) {
|
||||
options.onProblem(report);
|
||||
}
|
||||
} catch {
|
||||
// A transient read/IPC failure is not evidence of a configuration error.
|
||||
candidate = null;
|
||||
recovery = null;
|
||||
} finally {
|
||||
inFlight = false;
|
||||
if (wakePending) { wakePending = false; wake(); } else next(delay);
|
||||
}
|
||||
}
|
||||
function wake() {
|
||||
cancel?.(); cancel = undefined;
|
||||
if (stopped) return;
|
||||
if (inFlight) { wakePending = true; return; }
|
||||
void read();
|
||||
}
|
||||
return { wake, stop() { stopped = true; cancel?.(); cancel = undefined; candidate = null; } };
|
||||
}
|
||||
|
||||
// This is only notification deduplication, not a security or repair token.
|
||||
function labelHash(value: string) {
|
||||
let first = 2166136261;
|
||||
let second = 5381;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
first = Math.imul(first ^ value.charCodeAt(index), 16777619);
|
||||
second = Math.imul(second, 33) ^ value.charCodeAt(index);
|
||||
}
|
||||
return `${(first >>> 0).toString(16)}-${(second >>> 0).toString(16)}`;
|
||||
}
|
||||
export function configHealthIssueKey(report: ConfigHealthReport) {
|
||||
return labelHash(JSON.stringify(report.issues.map(({ code, title, description }) => [code, title, description]).sort()));
|
||||
}
|
||||
type StorageLike = Pick<Storage, "getItem" | "setItem">;
|
||||
export function createConfigHealthNoticeRegistry(storage?: StorageLike) {
|
||||
const storageKey = "codexx.configHealth.notified.v1";
|
||||
let entries: { scope: string; issue: string }[] = [];
|
||||
try {
|
||||
const saved: unknown = JSON.parse(storage?.getItem(storageKey) || "[]");
|
||||
if (Array.isArray(saved)) entries = saved.filter((value) => typeof value?.scope === "string" && typeof value?.issue === "string").slice(-32);
|
||||
} catch { /* Use in-memory suppression if browser storage is unavailable. */ }
|
||||
const scope = (report: ConfigHealthReport) => labelHash(report.codexDir.replace(/\\/g, "/"));
|
||||
const issue = configHealthIssueKey;
|
||||
const persist = () => { try { storage?.setItem(storageKey, JSON.stringify(entries)); } catch { /* Optional persistence. */ } };
|
||||
return {
|
||||
has(report: ConfigHealthReport) { return entries.some((entry) => entry.scope === scope(report) && entry.issue === issue(report)); },
|
||||
mark(report: ConfigHealthReport) {
|
||||
entries = [...entries.filter((entry) => entry.scope !== scope(report) || entry.issue !== issue(report)), { scope: scope(report), issue: issue(report) }].slice(-32);
|
||||
persist();
|
||||
},
|
||||
clear(report: ConfigHealthReport) {
|
||||
const remaining = entries.filter((entry) => entry.scope !== scope(report));
|
||||
if (remaining.length !== entries.length) { entries = remaining; persist(); }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export type ConfigHealthIssue = {
|
||||
code: string;
|
||||
title: string;
|
||||
description: string;
|
||||
repairable: boolean;
|
||||
};
|
||||
|
||||
export type ConfigHealthReport = {
|
||||
codexDir: string;
|
||||
fingerprint: string;
|
||||
status: "healthy" | "issues" | "missing" | "unavailable";
|
||||
issues: ConfigHealthIssue[];
|
||||
canRepair: boolean;
|
||||
repairSummary: string[];
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export type ConfigHealthRepairResult = {
|
||||
report: ConfigHealthReport;
|
||||
backupId: string | null;
|
||||
changed: boolean;
|
||||
};
|
||||
+356
-65
@@ -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,12 +22,15 @@ 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";
|
||||
import { useConfigHealth } from "./useConfigHealth";
|
||||
import { ConfigHealthPanel, ConfigHealthStatus } from "./components/ConfigHealthPanel";
|
||||
import { ConfigHealthToast } from "./components/ConfigHealthToast";
|
||||
import type {
|
||||
AboutInfo,
|
||||
ActionResult,
|
||||
@@ -574,6 +578,7 @@ function buildProviderTomlPreview(provider: SavedProvider) {
|
||||
`base_url = "${tomlEscape(baseUrl)}"`,
|
||||
`wire_api = "${tomlEscape(wireApi)}"`,
|
||||
`requires_openai_auth = ${provider.requiresOpenaiAuth ? "true" : "false"}`,
|
||||
`supports_websockets = false`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -750,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);
|
||||
@@ -768,6 +775,9 @@ function App() {
|
||||
const [skillsMcpImportPreview, setSkillsMcpImportPreview] = React.useState<SkillsMcpImportPreview | null>(null);
|
||||
const [skillsMcpImportOpen, setSkillsMcpImportOpen] = React.useState(false);
|
||||
const [startupDiagnostics, setStartupDiagnostics] = React.useState<StartupDiagnostics | null>(null);
|
||||
const [startupDiagnosticsError, setStartupDiagnosticsError] = React.useState("");
|
||||
const [startupDiagnosticsLoading, setStartupDiagnosticsLoading] = React.useState(false);
|
||||
const [startupCheckMode, setStartupCheckMode] = React.useState<"startup" | "manual">("startup");
|
||||
const [startupWizardOpen, setStartupWizardOpen] = React.useState(() => localStorage.getItem(STARTUP_WIZARD_SEEN_KEY) !== "1");
|
||||
const [startupClosing, setStartupClosing] = React.useState(false);
|
||||
const [sessionQuery, setSessionQuery] = React.useState("");
|
||||
@@ -781,6 +791,8 @@ function App() {
|
||||
const [state, setState] = React.useState<CodexState | null>(null);
|
||||
const [configDir, setConfigDir] = React.useState("");
|
||||
const [configDirDraft, setConfigDirDraft] = React.useState("");
|
||||
const [healthConfigDir, setHealthConfigDir] = React.useState("");
|
||||
const [settingsGeneralRequest, setSettingsGeneralRequest] = React.useState(0);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [refreshing, setRefreshing] = React.useState(true);
|
||||
const [toast, setToast] = React.useState<string>("");
|
||||
@@ -788,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("");
|
||||
@@ -809,12 +822,14 @@ function App() {
|
||||
const [promptModeHelpOpen, setPromptModeHelpOpen] = React.useState(false);
|
||||
const autoUpdateCheckedRef = React.useRef(false);
|
||||
const promptImportRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const skillZipImportRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const nativeTransferBusyRef = React.useRef(false);
|
||||
const [sessionExportBusy, setSessionExportBusy] = React.useState(false);
|
||||
const officialAuthEditorRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
const officialTomlEditorRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
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);
|
||||
@@ -822,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);
|
||||
@@ -843,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]);
|
||||
@@ -914,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";
|
||||
@@ -1035,6 +1070,7 @@ function App() {
|
||||
...providerForm,
|
||||
id: providerForm.id || customProviderId(providerForm.providerName || providerForm.baseUrl),
|
||||
},
|
||||
newProvider: creatingProvider,
|
||||
configDir: configDir || null,
|
||||
})
|
||||
.then((draft) => {
|
||||
@@ -1043,7 +1079,7 @@ function App() {
|
||||
.catch(() => {
|
||||
if (requestId === providerDraftRequestRef.current) setProviderTomlDraft(fallback);
|
||||
});
|
||||
}, [configDir, providerDraftRefreshToken, providerForm, providerMode, providerTomlDirty, providerTomlPreview, state?.configText]);
|
||||
}, [configDir, creatingProvider, providerDraftRefreshToken, providerForm, providerMode, providerTomlDirty, providerTomlPreview, state?.configText]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tab === "provider" && providerMode === "form") return;
|
||||
@@ -1153,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
|
||||
@@ -1179,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,
|
||||
@@ -1192,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;
|
||||
@@ -1219,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,
|
||||
@@ -1227,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,
|
||||
@@ -1244,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),
|
||||
@@ -1320,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;
|
||||
@@ -1330,6 +1395,7 @@ function App() {
|
||||
skillsMcpLoadedRef.current = "";
|
||||
skillsMcpAutoLoadAttemptedRef.current = "";
|
||||
const requestedConfigDir = configDirDraft.trim();
|
||||
setHealthConfigDir(requestedConfigDir);
|
||||
const resolvedConfigDir = requestedConfigDir || null;
|
||||
const activeCodexDir = state?.codexDir || configDir;
|
||||
setRefreshing(true);
|
||||
@@ -1346,16 +1412,36 @@ function App() {
|
||||
sessionLoadRequestRef.current += 1;
|
||||
|
||||
if (includeDiagnostics) {
|
||||
setStartupDiagnostics(null);
|
||||
setStartupDiagnosticsError("");
|
||||
setStartupDiagnosticsLoading(true);
|
||||
void invoke<StartupDiagnostics>("get_startup_diagnostics", { configDir: resolvedConfigDir })
|
||||
.then((diagnostics) => {
|
||||
if (requestId === refreshRequestRef.current) setStartupDiagnostics(diagnostics);
|
||||
if (requestId === refreshRequestRef.current) {
|
||||
setStartupDiagnostics(diagnostics);
|
||||
setStartupDiagnosticsLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestId === refreshRequestRef.current) setStartupDiagnostics(null);
|
||||
if (requestId === refreshRequestRef.current) {
|
||||
setStartupDiagnosticsError("unavailable");
|
||||
setStartupDiagnosticsLoading(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
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)
|
||||
@@ -1368,12 +1454,14 @@ function App() {
|
||||
setCreatingProvider(false);
|
||||
setEditingDetectedProvider(false);
|
||||
setProviderTomlDirty(false);
|
||||
setProviderCommonConfigDirty(false);
|
||||
setProviderTomlDraft("");
|
||||
setAvailableProviderModels([]);
|
||||
setProviderModelsLoading(false);
|
||||
}
|
||||
activeConfigDirKeyRef.current = normalizedConfigDirForComparison(next.codexDir);
|
||||
setConfigDir(next.codexDir);
|
||||
setHealthConfigDir(next.codexDir);
|
||||
setConfigDirDraft(next.codexDir);
|
||||
setState(next);
|
||||
setRefreshing(false);
|
||||
@@ -1401,13 +1489,110 @@ 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.
|
||||
const configHealth = useConfigHealth({
|
||||
configDir: healthConfigDir,
|
||||
canCheck: !refreshing && !loading && !actionBusy
|
||||
&& !(tab === "provider" && providerMode !== "list") && tab !== "toml",
|
||||
canNotify: !toast && !error && !startupWizardOpen && !updatePromptOpen
|
||||
&& !loading && !refreshing && !actionBusy
|
||||
&& !(tab === "provider" && providerMode !== "list") && tab !== "toml",
|
||||
lang,
|
||||
reviewing: startupWizardOpen,
|
||||
onHint: setToast,
|
||||
onRepaired: () => refresh(startupWizardOpen),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
refresh(startupWizardOpen);
|
||||
// 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;
|
||||
@@ -1779,10 +1964,11 @@ function App() {
|
||||
async () => {
|
||||
let provider = pendingProvider;
|
||||
if (!providerTomlDirty) {
|
||||
const draftSource = providerForm.tomlConfig?.trim() || state?.configText?.trim() || "";
|
||||
const draftSource = providerForm.tomlConfig?.trim() || "";
|
||||
const providerForDraft = normalizedProviderForm(draftSource);
|
||||
const latestDraft = await invoke<string>("build_provider_toml_draft", {
|
||||
provider: providerForDraft,
|
||||
newProvider: creatingProvider,
|
||||
configDir: configDir || null,
|
||||
});
|
||||
provider = { ...providerForDraft, tomlConfig: latestDraft.trimEnd() };
|
||||
@@ -1791,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");
|
||||
@@ -1804,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
|
||||
@@ -2260,24 +2447,15 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const installSkillZipFile = async (file?: File | null) => {
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
setError(lang === "zh" ? "请选择 .zip 技能包" : "Please choose a .zip skill package");
|
||||
return;
|
||||
}
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
setError(lang === "zh" ? "ZIP 技能包不能超过 20MB" : "Skill ZIP must be smaller than 20MB");
|
||||
return;
|
||||
}
|
||||
const installSkillZipFile = async () => {
|
||||
if (nativeTransferBusyRef.current) return;
|
||||
nativeTransferBusyRef.current = true;
|
||||
const request = beginSkillsMcpRequest();
|
||||
const actionToken = beginActionBusy("installSkillZip");
|
||||
setError("");
|
||||
try {
|
||||
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
|
||||
if (!isCurrentSkillsMcpRequest(request.requestId, request.configDirKey)) return;
|
||||
const result = await invoke<SkillsMcpActionResult>("install_skill_zip", { configDir: request.configDir, fileName: file.name, bytes });
|
||||
if (!isCurrentSkillsMcpRequest(request.requestId, request.configDirKey)) return;
|
||||
const result = await invoke<SkillsMcpActionResult | null>("import_skills_mcp_archive", { configDir: request.configDir, lang });
|
||||
if (!result || !isCurrentSkillsMcpRequest(request.requestId, request.configDirKey)) return;
|
||||
skillsMcpLoadedRef.current = request.configDirKey;
|
||||
setSkillsMcpState(result.state);
|
||||
setToast(result.message);
|
||||
@@ -2285,11 +2463,59 @@ function App() {
|
||||
if (isCurrentSkillsMcpRequest(request.requestId, request.configDirKey)) setError(String(e));
|
||||
} finally {
|
||||
endActionBusy(actionToken);
|
||||
if (skillZipImportRef.current) skillZipImportRef.current.value = "";
|
||||
nativeTransferBusyRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const exportSkillsMcp = async (kind: "mcp" | "skills") => {
|
||||
if (nativeTransferBusyRef.current) return;
|
||||
nativeTransferBusyRef.current = true;
|
||||
const request = beginSkillsMcpRequest();
|
||||
const actionToken = beginActionBusy("exportSkillsMcp");
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<{ path: string; exportedSkills: number; exportedMcp: number } | null>("export_skills_mcp_archive", { configDir: request.configDir, kind, lang });
|
||||
if (!result || !isCurrentSkillsMcpRequest(request.requestId, request.configDirKey)) return;
|
||||
const count = kind === "mcp" ? result.exportedMcp : result.exportedSkills;
|
||||
setToast(lang === "zh" ? `已导出 ${count} 个 ${kind === "mcp" ? "MCP" : "Skills"}` : `Exported ${count} ${kind === "mcp" ? "MCP servers" : "Skills"}`);
|
||||
} catch (e) {
|
||||
if (isCurrentSkillsMcpRequest(request.requestId, request.configDirKey)) setError(String(e));
|
||||
} finally {
|
||||
endActionBusy(actionToken);
|
||||
nativeTransferBusyRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const exportSessions = async (ids: string[]) => {
|
||||
if (!ids.length || nativeTransferBusyRef.current) return;
|
||||
nativeTransferBusyRef.current = true;
|
||||
setSessionExportBusy(true);
|
||||
const directory = configDir;
|
||||
const directoryKey = normalizedConfigDirForComparison(directory);
|
||||
const generation = refreshRequestRef.current;
|
||||
const isCurrentExport = () => generation === refreshRequestRef.current && activeConfigDirKeyRef.current === directoryKey;
|
||||
const actionToken = beginActionBusy("exportSessions");
|
||||
setError("");
|
||||
try {
|
||||
const suggestedName = ids.length === 1
|
||||
? sessionStatus?.sessions.find((session) => session.id === ids[0])?.title || "Codex-session"
|
||||
: `Codex-sessions-${new Date().toISOString().slice(0, 10)}`;
|
||||
const result = await invoke<{ path: string; exportedSessions: number; failedSessions: number; warnings: string[] } | null>("export_codex_sessions", { configDir: directory || null, sessionIds: ids, suggestedName, lang });
|
||||
if (!result || !isCurrentExport()) return;
|
||||
const message = lang === "zh" ? `已导出 ${result.exportedSessions} 个会话` : `Exported ${result.exportedSessions} conversation(s)`;
|
||||
if (result.failedSessions || result.warnings.length) setError(`${message};${result.warnings.join(";")}`);
|
||||
else setToast(message);
|
||||
} catch (e) {
|
||||
if (isCurrentExport()) setError(String(e));
|
||||
} finally {
|
||||
endActionBusy(actionToken);
|
||||
setSessionExportBusy(false);
|
||||
nativeTransferBusyRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -2420,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) => {
|
||||
@@ -2473,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);
|
||||
@@ -2486,6 +2728,7 @@ function App() {
|
||||
setProviderForm(provider);
|
||||
setProviderTomlDraft(provider.tomlConfig?.trim() || buildProviderTomlPreview(provider));
|
||||
setProviderTomlDirty(false);
|
||||
setProviderCommonConfigDirty(false);
|
||||
setProviderMode("form");
|
||||
};
|
||||
|
||||
@@ -2512,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(
|
||||
@@ -2533,6 +2777,7 @@ function App() {
|
||||
setProviderForm(next);
|
||||
setProviderTomlDraft(next.tomlConfig || buildProviderTomlPreview(next));
|
||||
setProviderTomlDirty(false);
|
||||
setProviderCommonConfigDirty(false);
|
||||
setProviderMode("form");
|
||||
};
|
||||
|
||||
@@ -2599,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);
|
||||
@@ -2710,7 +2955,7 @@ function App() {
|
||||
};
|
||||
|
||||
const closeStartupWizard = () => {
|
||||
localStorage.setItem(STARTUP_WIZARD_SEEN_KEY, "1");
|
||||
if (startupCheckMode === "startup") localStorage.setItem(STARTUP_WIZARD_SEEN_KEY, "1");
|
||||
setStartupClosing(true);
|
||||
window.setTimeout(() => {
|
||||
setStartupWizardOpen(false);
|
||||
@@ -2728,6 +2973,14 @@ function App() {
|
||||
setTab(nextTab);
|
||||
};
|
||||
|
||||
const openConfigurationChecks = () => {
|
||||
configHealth.dismiss();
|
||||
setStartupCheckMode("manual");
|
||||
setStartupClosing(false);
|
||||
setStartupWizardOpen(true);
|
||||
refresh(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
activeTab={tab}
|
||||
@@ -2759,6 +3012,19 @@ function App() {
|
||||
onDismissMessage={() => setToast("")}
|
||||
onDismissError={() => setError("")}
|
||||
/>
|
||||
{configHealth.noticeVisible && configHealth.notice && <ConfigHealthToast
|
||||
lang={lang}
|
||||
report={configHealth.notice}
|
||||
repairing={configHealth.repairing}
|
||||
onRepair={() => void configHealth.repair()}
|
||||
onDismiss={() => configHealth.dismiss(true)}
|
||||
onOpenSettings={() => {
|
||||
configHealth.dismiss();
|
||||
setSettingsGeneralRequest((value) => value + 1);
|
||||
changeTab("settings");
|
||||
openConfigurationChecks();
|
||||
}}
|
||||
/>}
|
||||
<UpdateDialog
|
||||
open={updatePromptOpen && Boolean(releaseInfo.hasUpdate)}
|
||||
lang={lang}
|
||||
@@ -2776,11 +3042,25 @@ function App() {
|
||||
/>
|
||||
<StartupWizardDialog
|
||||
open={startupWizardOpen}
|
||||
mode={startupCheckMode}
|
||||
closing={startupClosing}
|
||||
lang={lang}
|
||||
diagnostics={startupDiagnostics}
|
||||
diagnosticsError={startupDiagnosticsError ? (lang === "zh" ? "暂时无法读取环境信息,请重新检查。配置检查仍可使用。" : "Environment information is unavailable. Try again; configuration checks remain available.") : ""}
|
||||
configDir={configDirDraft}
|
||||
loading={loading || refreshing}
|
||||
loading={loading || refreshing || startupDiagnosticsLoading || configHealth.checking || configHealth.repairing}
|
||||
configHealthPanel={normalizedConfigDirForComparison(configDirDraft.trim()) !== normalizedConfigDirForComparison(healthConfigDir)
|
||||
? <p className="cx-config-health-note" role="status">{lang === "zh" ? "目录已更改,请先点击「重新检查」查看此目录的配置。" : "The directory has changed. Choose Recheck to review its configuration."}</p>
|
||||
: <ConfigHealthPanel
|
||||
lang={lang}
|
||||
report={configHealth.report}
|
||||
checking={configHealth.checking || loading || refreshing || Boolean(actionBusy)}
|
||||
repairing={configHealth.repairing}
|
||||
error={configHealth.error}
|
||||
onCheck={() => void configHealth.check()}
|
||||
onRepair={() => void configHealth.repair()}
|
||||
onOpenConfig={() => void configHealth.openConfig()}
|
||||
/>}
|
||||
onConfigDirChange={setConfigDirDraft}
|
||||
onRecheck={() => refresh(true)}
|
||||
onSkip={closeStartupWizard}
|
||||
@@ -2815,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}
|
||||
@@ -2841,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 || "",
|
||||
@@ -2918,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 }))}
|
||||
@@ -2946,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;
|
||||
@@ -2959,6 +3243,7 @@ function App() {
|
||||
|| providerTomlPreview,
|
||||
);
|
||||
setProviderTomlDirty(false);
|
||||
setProviderCommonConfigDirty(false);
|
||||
setProviderDraftRefreshToken((token) => token + 1);
|
||||
}}
|
||||
onSaveProvider={saveProviderConfig}
|
||||
@@ -2990,6 +3275,8 @@ function App() {
|
||||
sessionDeleteConfirmOpen={sessionDeleteConfirmOpen}
|
||||
sessionDeleteBusy={sessionDeleteBusy}
|
||||
sessionDeleteSafetyConfirmed={sessionDeleteSafetyConfirmed}
|
||||
sessionExportBusy={sessionExportBusy}
|
||||
onExportSessions={exportSessions}
|
||||
onCheckSessions={checkSessions}
|
||||
onSyncSessions={syncSessions}
|
||||
onSessionQueryChange={(value) => {
|
||||
@@ -3023,7 +3310,6 @@ function App() {
|
||||
actionBusy={actionBusy}
|
||||
importOpen={skillsMcpImportOpen}
|
||||
importPreview={skillsMcpImportPreview}
|
||||
zipInputRef={skillZipImportRef}
|
||||
className={tab !== "skillsMcp" ? "page-pane-hidden" : undefined}
|
||||
onTabChange={setSkillsMcpTab}
|
||||
onLoad={loadSkillsMcp}
|
||||
@@ -3031,6 +3317,7 @@ function App() {
|
||||
onCloseImportPreview={() => setSkillsMcpImportOpen(false)}
|
||||
onConfirmImport={importExistingSkillsMcp}
|
||||
onInstallZip={installSkillZipFile}
|
||||
onExport={exportSkillsMcp}
|
||||
onCheckUpdates={checkSkillUpdatesAction}
|
||||
onToggleSkill={toggleSkillEnabled}
|
||||
onToggleMcp={toggleMcpEnabled}
|
||||
@@ -3159,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}`)}
|
||||
@@ -3174,6 +3460,15 @@ function App() {
|
||||
<SettingsPage
|
||||
lang={lang}
|
||||
configDir={configDir}
|
||||
onChange={async () => { routingRequestRefreshRef.current(); }}
|
||||
generalRequest={settingsGeneralRequest}
|
||||
configHealthStatus={<ConfigHealthStatus
|
||||
lang={lang}
|
||||
report={configHealth.report}
|
||||
checking={configHealth.checking || loading || refreshing || Boolean(actionBusy)}
|
||||
repairing={configHealth.repairing}
|
||||
error={configHealth.error}
|
||||
/>}
|
||||
copy={{
|
||||
eyebrow: "Settings",
|
||||
title: t.settings.title,
|
||||
@@ -3184,11 +3479,11 @@ function App() {
|
||||
productTitle: t.settings.productName,
|
||||
productDescription: t.settings.productDesc,
|
||||
productValue: "Codex-X",
|
||||
recheckTitle: lang === "zh" ? "首次启动向导" : "First-run wizard",
|
||||
recheckTitle: lang === "zh" ? "环境与配置检查" : "Environment & configuration check",
|
||||
recheckDescription: lang === "zh"
|
||||
? "重新检测 CODEX_HOME、config.toml、auth.json 和 SQLite 会话库。"
|
||||
: "Recheck CODEX_HOME, config.toml, auth.json and SQLite session stores.",
|
||||
recheckLabel: lang === "zh" ? "重新检测" : "Recheck",
|
||||
? "查看 Codex 环境与配置状态,按需检查和修复。"
|
||||
: "Review your Codex environment and configuration, and repair issues when needed.",
|
||||
recheckLabel: lang === "zh" ? "检查" : "Check",
|
||||
restartTitle: lang === "zh" ? "Codex 桌面客户端" : "Codex desktop app",
|
||||
restartDescription: lang === "zh"
|
||||
? "重新启动本机的 Codex(ChatGPT)桌面客户端,不会重启 Codex-X。"
|
||||
@@ -3207,11 +3502,7 @@ function App() {
|
||||
recheckBusy={loading || refreshing}
|
||||
restartBusy={restartCodexBusy}
|
||||
onRestartCodex={restartCodexDesktop}
|
||||
onRecheck={() => {
|
||||
localStorage.removeItem(STARTUP_WIZARD_SEEN_KEY);
|
||||
setStartupWizardOpen(true);
|
||||
refresh(true);
|
||||
}}
|
||||
onRecheck={openConfigurationChecks}
|
||||
/>
|
||||
)}
|
||||
</PageTransition>
|
||||
|
||||
@@ -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,18 +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 = {
|
||||
@@ -31,6 +83,8 @@ export type SessionPreview = {
|
||||
|
||||
export type SessionSyncStatus = {
|
||||
codexDir: string;
|
||||
directoryIdentities?: SessionDirectoryIdentities;
|
||||
pinScopeKey?: string | null;
|
||||
targetProvider: string;
|
||||
rolloutFiles: number;
|
||||
sessionMetaCount: number;
|
||||
@@ -73,6 +127,7 @@ type SessionManagementPageProps = {
|
||||
actionBusy: string;
|
||||
sessionDeleteConfirmOpen: boolean;
|
||||
sessionDeleteBusy: boolean;
|
||||
sessionExportBusy: boolean;
|
||||
sessionDeleteSafetyConfirmed: boolean;
|
||||
onCheckSessions: () => void;
|
||||
onSyncSessions: () => void;
|
||||
@@ -84,6 +139,7 @@ type SessionManagementPageProps = {
|
||||
onSetSessionGroupSelected: (sessions: SessionPreview[], checked: boolean) => void;
|
||||
onCloseDeleteConfirm: () => void;
|
||||
onDeleteSelectedSessions: () => void;
|
||||
onExportSessions: (ids: string[]) => void;
|
||||
onDeleteSafetyConfirmedChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
@@ -138,6 +194,7 @@ export function SessionManagementPage({
|
||||
actionBusy,
|
||||
sessionDeleteConfirmOpen,
|
||||
sessionDeleteBusy,
|
||||
sessionExportBusy,
|
||||
sessionDeleteSafetyConfirmed,
|
||||
onCheckSessions,
|
||||
onSyncSessions,
|
||||
@@ -149,14 +206,16 @@ export function SessionManagementPage({
|
||||
onSetSessionGroupSelected,
|
||||
onCloseDeleteConfirm,
|
||||
onDeleteSelectedSessions,
|
||||
onExportSessions,
|
||||
onDeleteSafetyConfirmedChange,
|
||||
}: SessionManagementPageProps) {
|
||||
const isChinese = lang === "zh";
|
||||
const codexDir = sessionStatus?.codexDir || "default";
|
||||
const copy = isChinese
|
||||
? {
|
||||
syncEyebrow: "会话同步",
|
||||
title: "会话管理",
|
||||
description: "检查本地会话是否位于官方与中转共用的会话列表,需要时一键同步。不会修改聊天内容。",
|
||||
description: "同步普通会话到共享列表,内部任务不参与同步。不会修改聊天内容。",
|
||||
syncTo: "同步到",
|
||||
check: "检查会话",
|
||||
checking: "检查中...",
|
||||
@@ -168,26 +227,38 @@ 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: "导出选中",
|
||||
exportOne: "导出为 Markdown",
|
||||
exporting: "导出中…",
|
||||
exportHint: "单个会话保存为 Markdown;多个会话打包为 ZIP",
|
||||
deleteMany: (count: number) => `永久删除 ${count} 条`,
|
||||
selectAll: "选择当前列表中的全部会话",
|
||||
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} 条会话`,
|
||||
@@ -205,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...",
|
||||
@@ -217,26 +288,38 @@ 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",
|
||||
exportOne: "Export as Markdown",
|
||||
exporting: "Exporting…",
|
||||
exportHint: "Save one session as Markdown, or multiple sessions in a ZIP",
|
||||
deleteMany: (count: number) => `Delete ${count} permanently`,
|
||||
selectAll: "Select all sessions in the current list",
|
||||
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)`,
|
||||
@@ -258,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
|
||||
@@ -387,14 +633,26 @@ 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)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="cx-session-button cx-session-button--secondary"
|
||||
onClick={() => onExportSessions(selectedSessionIds)}
|
||||
disabled={loading || sessionDeleteBusy || sessionExportBusy || selectedSessionIds.length === 0}
|
||||
title={copy.exportHint}
|
||||
aria-busy={sessionExportBusy}
|
||||
>
|
||||
{sessionExportBusy ? <Loader2 size={15} className="cx-session-spin" aria-hidden="true" /> : <Download size={15} strokeWidth={1.9} aria-hidden="true" />}
|
||||
{sessionExportBusy ? copy.exporting : copy.exportSelected}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cx("cx-session-button cx-session-delete-trigger", selectedSessionIds.length > 0 ? "cx-session-button--danger" : "cx-session-button--secondary")}
|
||||
onClick={onOpenDeleteConfirm}
|
||||
disabled={loading || sessionDeleteBusy || selectedSessionIds.length === 0}
|
||||
disabled={loading || sessionDeleteBusy || sessionExportBusy || selectedSessionIds.length === 0}
|
||||
title={selectedSessionIds.length > 0 ? undefined : copy.deleteSelected}
|
||||
>
|
||||
<Trash2 size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
@@ -402,57 +660,112 @@ 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>ID</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) => (
|
||||
<label className={cx("cx-session-row", item.needsSync && "cx-session-row--needs-sync", selectedSessionSet.has(item.id) && "cx-session-row--selected")} key={item.id}>
|
||||
<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",
|
||||
"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);
|
||||
}}
|
||||
>
|
||||
<span className="cx-session-select-box" title={copy.selectSession}>
|
||||
<input
|
||||
className="cx-session-checkbox"
|
||||
type="checkbox"
|
||||
checked={selectedSessionSet.has(item.id)}
|
||||
disabled={loading || sessionDeleteBusy}
|
||||
onChange={() => onToggleSessionSelected(item.id)}
|
||||
aria-label={`${copy.selectSession}: ${item.title || (isChinese ? "未命名会话" : "Untitled session")} (#${shortId(item.id)})`}
|
||||
/>
|
||||
@@ -461,28 +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>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<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 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 ? (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useId, useState } from "react";
|
||||
import type { ChangeEvent, RefObject } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Blocks,
|
||||
@@ -32,14 +31,14 @@ export type SkillsMcpPageProps = {
|
||||
noteBusyKey: string;
|
||||
importOpen: boolean;
|
||||
importPreview: SkillsMcpImportPreview | null;
|
||||
zipInputRef: RefObject<HTMLInputElement>;
|
||||
className?: string;
|
||||
onTabChange: (tab: SkillsMcpTab) => void;
|
||||
onLoad: MaybeAsyncAction;
|
||||
onOpenImportPreview: MaybeAsyncAction;
|
||||
onCloseImportPreview: () => void;
|
||||
onConfirmImport: MaybeAsyncAction;
|
||||
onInstallZip: (file?: File | null) => void | Promise<void>;
|
||||
onInstallZip: MaybeAsyncAction;
|
||||
onExport: (kind: SkillsMcpTab) => void | Promise<void>;
|
||||
onCheckUpdates: MaybeAsyncAction;
|
||||
onToggleSkill: (id: string, enabled: boolean) => void | Promise<void>;
|
||||
onToggleMcp: (id: string, enabled: boolean) => void | Promise<void>;
|
||||
@@ -63,7 +62,9 @@ function getCopy(lang: Lang) {
|
||||
description: "管理 Codex 当前可用的 Skills 与 MCP,导入已有内容、安装技能包并控制启用状态。",
|
||||
refresh: "刷新",
|
||||
importExisting: "导入已有",
|
||||
installZip: "从 ZIP 安装",
|
||||
installZip: "从 ZIP 导入",
|
||||
exportZip: "导出 ZIP",
|
||||
exportHint: "导出当前标签页的全部内容,可通过 ZIP 再次导入;MCP 包可能包含连接密钥,请妥善保存。",
|
||||
checkUpdates: "检查更新",
|
||||
loading: "正在读取本地 Skills / MCP...",
|
||||
mcpHelp: (count: number) => `当前共有 ${count} 个 MCP,启用后会写入 Codex config.toml。`,
|
||||
@@ -102,7 +103,9 @@ function getCopy(lang: Lang) {
|
||||
description: "Manage the Skills and MCP servers available to Codex, import existing items, install packages, and control their state.",
|
||||
refresh: "Refresh",
|
||||
importExisting: "Import existing",
|
||||
installZip: "Install ZIP",
|
||||
installZip: "Import ZIP",
|
||||
exportZip: "Export ZIP",
|
||||
exportHint: "Export all items in this tab for ZIP import. MCP archives may contain connection credentials; keep them private.",
|
||||
checkUpdates: "Check updates",
|
||||
loading: "Loading local Skills / MCP...",
|
||||
mcpHelp: (count: number) => `${count} MCP server(s). Enabling one writes it to Codex config.toml.`,
|
||||
@@ -311,7 +314,6 @@ export function SkillsMcpPage({
|
||||
noteBusyKey,
|
||||
importOpen,
|
||||
importPreview,
|
||||
zipInputRef,
|
||||
className,
|
||||
onTabChange,
|
||||
onLoad,
|
||||
@@ -319,6 +321,7 @@ export function SkillsMcpPage({
|
||||
onCloseImportPreview,
|
||||
onConfirmImport,
|
||||
onInstallZip,
|
||||
onExport,
|
||||
onCheckUpdates,
|
||||
onToggleSkill,
|
||||
onToggleMcp,
|
||||
@@ -350,9 +353,6 @@ export function SkillsMcpPage({
|
||||
setNoteTarget(null);
|
||||
}
|
||||
};
|
||||
const handleZipChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
void onInstallZip(event.currentTarget.files?.[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={cx("cx-skills-page", className)} aria-label={copy.title}>
|
||||
@@ -363,16 +363,6 @@ export function SkillsMcpPage({
|
||||
<span>{copy.description}</span>
|
||||
</div>
|
||||
<div className="cx-skills-actions">
|
||||
<input
|
||||
ref={zipInputRef}
|
||||
className="cx-skills-file-input"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={handleZipChange}
|
||||
disabled={anyBusy}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={actionBusy === "loadSkillsMcp" ? <Loader2 className="cx-skills-spin" /> : <RefreshCw />}
|
||||
@@ -392,7 +382,7 @@ export function SkillsMcpPage({
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={actionBusy === "installSkillZip" ? <Loader2 className="cx-skills-spin" /> : <Upload />}
|
||||
onClick={() => zipInputRef.current?.click()}
|
||||
onClick={() => run(onInstallZip)}
|
||||
disabled={anyBusy}
|
||||
>
|
||||
{copy.installZip}
|
||||
@@ -456,7 +446,12 @@ export function SkillsMcpPage({
|
||||
{activeTab === "mcp" ? <PlugZap size={17} aria-hidden="true" /> : <PackageOpen size={17} aria-hidden="true" />}
|
||||
<h3>{activeTab === "mcp" ? "MCP" : "Skills"}</h3>
|
||||
</div>
|
||||
<span>{copy.total(activeItems.length)}</span>
|
||||
<div className="cx-skills-list-tools">
|
||||
<span>{copy.total(activeItems.length)}</span>
|
||||
<Button variant="ghost" size="sm" icon={actionBusy === "exportSkillsMcp" ? <Loader2 className="cx-skills-spin" /> : <Download />}
|
||||
title={copy.exportHint} disabled={anyBusy || activeItems.length === 0}
|
||||
onClick={() => void onExport(activeTab)}>{copy.exportZip}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cx-skills-list">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useId, useRef, useState } from "react";
|
||||
import { createContext, useContext, useEffect, useId, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
@@ -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";
|
||||
@@ -112,18 +114,27 @@ export type SettingsPageProps = {
|
||||
onRestartCodex: () => Promise<boolean>;
|
||||
recheckBusy?: boolean;
|
||||
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;
|
||||
@@ -156,9 +167,14 @@ export function SettingsPage({
|
||||
onRestartCodex,
|
||||
recheckBusy = false,
|
||||
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);
|
||||
@@ -174,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
|
||||
@@ -227,6 +244,8 @@ export function SettingsPage({
|
||||
title={copy.recheckTitle}
|
||||
description={copy.recheckDescription}
|
||||
action={(
|
||||
<div className="cx-settings-check-actions">
|
||||
{configHealthStatus}
|
||||
<button
|
||||
type="button"
|
||||
className="cx-page-button cx-page-button--secondary"
|
||||
@@ -236,6 +255,7 @@ export function SettingsPage({
|
||||
{recheckBusy && <Loader2 size={15} className="cx-page-spin" aria-hidden="true" />}
|
||||
{copy.recheckLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -260,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%); }
|
||||
@@ -250,6 +256,61 @@
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.cx-startup-path-control--manual {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-startup-config-health {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--ui-border-subtle);
|
||||
}
|
||||
|
||||
.cx-startup-diagnostics-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--ui-border-subtle);
|
||||
border-radius: 8px;
|
||||
color: var(--ui-text-muted);
|
||||
background: var(--ui-surface-elevated);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cx-startup-diagnostics-notice > svg {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.cx-startup-diagnostics-notice strong {
|
||||
color: var(--ui-text);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.cx-startup-diagnostics-notice p {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cx-startup-diagnostics-notice strong + p {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.cx-startup-diagnostics-notice--error {
|
||||
border-color: color-mix(in srgb, var(--ui-warning) 25%, transparent);
|
||||
background: color-mix(in srgb, var(--ui-warning) 8%, transparent);
|
||||
}
|
||||
|
||||
.cx-startup-diagnostics-notice--error > svg {
|
||||
color: var(--ui-warning);
|
||||
}
|
||||
|
||||
.cx-startup-diagnostics-notice + .cx-startup-checks {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.cx-startup-path-control label {
|
||||
padding-left: 5px;
|
||||
color: #777d86;
|
||||
@@ -340,6 +401,7 @@
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.cx-startup-path-control { grid-template-columns: 1fr auto; }
|
||||
.cx-startup-path-control--manual { grid-template-columns: minmax(0, 1fr); }
|
||||
.cx-startup-path-control label { grid-column: 1 / -1; }
|
||||
.cx-startup-checks { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
.cx-config-health,
|
||||
.cx-settings-check-actions,
|
||||
.cx-config-health-toast {
|
||||
--cx-health-surface: #fff;
|
||||
--cx-health-text: #303640;
|
||||
--cx-health-muted: #6b7280;
|
||||
--cx-health-border: #e3e6eb;
|
||||
--cx-health-blue: #2563eb;
|
||||
--cx-health-blue-soft: #eef4ff;
|
||||
--cx-health-warning: #976216;
|
||||
--cx-health-warning-soft: #fff8e9;
|
||||
--cx-health-warning-border: #efdab2;
|
||||
--cx-health-success: #17845d;
|
||||
--cx-health-success-soft: #edf8f2;
|
||||
}
|
||||
|
||||
.cx-config-health,
|
||||
.cx-config-health-toast { color: var(--cx-health-text); background: var(--cx-health-surface); }
|
||||
.cx-settings-check-actions { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.cx-config-health {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--cx-health-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 3px 12px rgb(27 35 48 / 3.5%);
|
||||
}
|
||||
|
||||
.cx-config-health-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cx-config-health-icon {
|
||||
display: grid;
|
||||
flex: 0 0 38px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border: 1px solid color-mix(in srgb, var(--cx-health-blue) 16%, var(--cx-health-border));
|
||||
border-radius: 8px;
|
||||
color: var(--cx-health-blue);
|
||||
background: var(--cx-health-blue-soft);
|
||||
}
|
||||
|
||||
.cx-config-health-heading-copy { flex: 1; min-width: 0; }
|
||||
.cx-config-health h3 { margin: 0; font-size: 14px; font-weight: 680; line-height: 1.4; }
|
||||
.cx-config-health-heading-copy p,
|
||||
.cx-config-health-note,
|
||||
.cx-config-health-error { margin: 5px 0 0; color: var(--cx-health-muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.cx-config-health-status { display: inline-flex; align-items: center; gap: 5px; flex-shrink: 0; padding: 5px 9px; border-radius: 6px; color: var(--cx-health-muted); background: var(--cx-health-blue-soft); font-size: 12px; font-weight: 600; }
|
||||
.cx-config-health-status--warning { color: var(--cx-health-warning); background: var(--cx-health-warning-soft); }
|
||||
.cx-config-health-status--success { color: var(--cx-health-success); background: var(--cx-health-success-soft); }
|
||||
.cx-config-health-error { margin-top: 14px; color: var(--cx-health-warning); }
|
||||
.cx-config-health-details { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--cx-health-border); }
|
||||
.cx-config-health-issues { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
||||
.cx-config-health-issues > li { display: flex; align-items: flex-start; gap: 8px; min-width: 0; }
|
||||
.cx-config-health-issues > li > svg { flex-shrink: 0; margin-top: 3px; color: var(--cx-health-warning); }
|
||||
.cx-config-health-issues strong { font-size: 13px; font-weight: 650; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.cx-config-health-issues p { margin: 3px 0 0; color: var(--cx-health-muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.cx-config-health-plan { margin-top: 13px; padding: 11px 13px; border-radius: 7px; background: var(--cx-health-blue-soft); font-size: 12px; line-height: 1.7; }
|
||||
.cx-config-health-plan strong { font-weight: 600; }
|
||||
.cx-config-health-plan ul { display: grid; gap: 3px; margin: 5px 0 0; padding-left: 18px; overflow-wrap: anywhere; }
|
||||
.cx-config-health-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-top: 14px; }
|
||||
.cx-config-health-footer > p { flex: 1 1 220px; margin: 0; }
|
||||
.cx-config-health-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
|
||||
.cx-config-health-toast {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
inset-inline: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 100px);
|
||||
overflow: auto;
|
||||
margin-inline: auto;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--cx-health-warning-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 32px rgb(25 32 44 / 15%), 0 2px 7px rgb(25 32 44 / 8%);
|
||||
pointer-events: auto;
|
||||
animation: cx-config-health-enter 180ms ease-out both;
|
||||
}
|
||||
|
||||
.cx-app-shell--mac .cx-config-health-toast { top: 52px; }
|
||||
.cx-config-health-toast-icon { display: grid; flex: 0 0 34px; width: 34px; height: 34px; place-items: center; border-radius: 9px; color: var(--cx-health-warning); background: var(--cx-health-warning-soft); }
|
||||
.cx-config-health-toast-content { min-width: 0; flex: 1; }
|
||||
.cx-config-health-toast-content strong { display: block; color: inherit; font-size: 14px; font-weight: 650; line-height: 1.5; }
|
||||
.cx-config-health-toast-content p { margin: 5px 0 0; color: var(--cx-health-muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.cx-config-health-toast-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
|
||||
.cx-config-health-toast-content p.cx-config-health-toast-plan { margin-top: 9px; padding: 8px 10px; border-radius: 6px; color: var(--cx-health-text); background: var(--cx-health-blue-soft); }
|
||||
.cx-config-health-toast-content p.cx-config-health-toast-hint { margin-top: 10px; font-size: 11px; }
|
||||
.cx-config-health-toast-close { display: grid; flex: 0 0 25px; width: 25px; height: 25px; place-items: center; padding: 0; border: 0; border-radius: 5px; color: var(--cx-health-muted); background: transparent; }
|
||||
.cx-config-health-toast-close:hover:not(:disabled) { color: var(--cx-health-text); background: var(--cx-health-blue-soft); }
|
||||
.cx-config-health-toast-close:focus-visible { outline: 2px solid var(--cx-health-blue); outline-offset: 2px; }
|
||||
.cx-config-health-spinner { animation: cx-config-health-spin 900ms linear infinite; }
|
||||
|
||||
:root[data-theme="dark"] .cx-config-health,
|
||||
:root[data-theme="dark"] .cx-settings-check-actions,
|
||||
:root[data-theme="dark"] .cx-config-health-toast {
|
||||
--cx-health-surface: #30343c;
|
||||
--cx-health-text: #e5e7eb;
|
||||
--cx-health-muted: #acb2be;
|
||||
--cx-health-border: #454b56;
|
||||
--cx-health-blue: #91b8fa;
|
||||
--cx-health-blue-soft: #2b3d59;
|
||||
--cx-health-warning: #ebc083;
|
||||
--cx-health-warning-soft: #443c30;
|
||||
--cx-health-warning-border: #6b573a;
|
||||
--cx-health-success: #80d4af;
|
||||
--cx-health-success-soft: #273f38;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.cx-config-health-heading { flex-wrap: wrap; gap: 10px; }
|
||||
.cx-config-health-heading > .cx-config-health-status { margin-left: 48px; }
|
||||
.cx-config-health-heading-copy { flex-basis: calc(100% - 48px); }
|
||||
.cx-config-health-actions { width: 100%; }
|
||||
}
|
||||
|
||||
@keyframes cx-config-health-enter { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes cx-config-health-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .cx-config-health-toast, .cx-config-health-spinner { animation: none; } }
|
||||
@@ -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);
|
||||
@@ -830,6 +876,43 @@
|
||||
border-right-color: var(--cx-dark-border);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-import-summary > div {
|
||||
background: var(--cx-dark-surface-raised);
|
||||
border-color: var(--cx-dark-border);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-import-summary strong {
|
||||
color: #9fc2ff;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-import-list {
|
||||
border-color: var(--cx-dark-border);
|
||||
background: var(--cx-dark-surface);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-import-section-head strong,
|
||||
:root[data-theme="dark"] .cx-skills-import-row strong {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-import-summary span,
|
||||
:root[data-theme="dark"] .cx-skills-import-section-head span,
|
||||
:root[data-theme="dark"] .cx-skills-import-list > p,
|
||||
:root[data-theme="dark"] .cx-skills-import-row span,
|
||||
:root[data-theme="dark"] .cx-skills-import-row em {
|
||||
color: #b6bac2;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-import-warnings {
|
||||
background: #3c352b;
|
||||
border-color: #655238;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-import-warnings > strong,
|
||||
:root[data-theme="dark"] .cx-skills-import-warnings p {
|
||||
color: #e8c58d;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-prompts-mode-title strong {
|
||||
color: #d8dbe0;
|
||||
}
|
||||
|
||||
@@ -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(240px, 1fr) 104px 104px 112px 112px;
|
||||
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,6 +870,66 @@
|
||||
color: #4669a8;
|
||||
}
|
||||
|
||||
.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;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 7px;
|
||||
color: var(--ui-text-muted);
|
||||
background: var(--ui-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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-pin-action:focus-visible {
|
||||
outline: 2px solid var(--ui-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -881,12 +1147,20 @@
|
||||
}
|
||||
|
||||
@media (max-width: 1240px) {
|
||||
.cx-session-column-head,
|
||||
.cx-session-row {
|
||||
grid-template-columns: 26px minmax(220px, 1fr) 96px 96px 104px;
|
||||
.cx-session-browser {
|
||||
grid-template-columns: 210px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-session-column-head span:last-child,
|
||||
.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 70px;
|
||||
}
|
||||
|
||||
.cx-session-id-heading,
|
||||
.cx-session-meta--id {
|
||||
display: none;
|
||||
}
|
||||
@@ -927,12 +1201,26 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.cx-session-column-head,
|
||||
.cx-session-row {
|
||||
grid-template-columns: 26px minmax(0, 1fr) 82px;
|
||||
.cx-session-browser {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(110px, 30%) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-session-column-head span:nth-child(n + 4),
|
||||
.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 70px;
|
||||
}
|
||||
|
||||
.cx-session-column-head span:nth-child(n + 4):not(.cx-session-actions-heading),
|
||||
.cx-session-meta--provider,
|
||||
.cx-session-meta--model,
|
||||
.cx-session-meta--id {
|
||||
|
||||
@@ -136,6 +136,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cx-skills-list-tools { display: flex; align-items: center; gap: 12px; }
|
||||
.cx-skills-list-tools > span { color: var(--cx-tool-muted); font-size: 12px; }
|
||||
.cx-skills-list-tools .ui-button { min-height: 30px; padding: 4px 9px; font-size: 12px; box-shadow: none; }
|
||||
|
||||
.cx-skills-spin,
|
||||
.cx-prompts-spin {
|
||||
animation: cx-tool-spin 0.8s linear infinite;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { configHealthIssueKey, createConfigHealthMonitor, createConfigHealthNoticeRegistry } from "./configHealthMonitor";
|
||||
import type { ConfigHealthRepairResult, ConfigHealthReport } from "./configHealthTypes";
|
||||
|
||||
export function useConfigHealth(options: {
|
||||
configDir: string;
|
||||
canCheck: boolean;
|
||||
canNotify: boolean;
|
||||
reviewing?: boolean;
|
||||
lang: "zh" | "en";
|
||||
onHint: (text: string) => void;
|
||||
onRepaired: () => void;
|
||||
}) {
|
||||
const runtime = useRef(options); runtime.current = options;
|
||||
const scope = useRef(options.configDir); scope.current = options.configDir;
|
||||
const mounted = useRef(true);
|
||||
const revision = useRef(0);
|
||||
const busyRef = useRef(false);
|
||||
const monitor = useRef<ReturnType<typeof createConfigHealthMonitor> | null>(null);
|
||||
const registry = useRef<ReturnType<typeof createConfigHealthNoticeRegistry> | null>(null);
|
||||
if (!registry.current) {
|
||||
let storage: Storage | undefined;
|
||||
try { storage = window.localStorage; } catch { /* In-memory fallback. */ }
|
||||
registry.current = createConfigHealthNoticeRegistry(storage);
|
||||
}
|
||||
const [report, setReport] = useState<ConfigHealthReport | null>(null);
|
||||
const [notice, setNotice] = useState<ConfigHealthReport | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [repairing, setRepairing] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [visible, setVisible] = useState(document.visibilityState !== "hidden");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const modalOpenRef = useRef(modalOpen); modalOpenRef.current = modalOpen;
|
||||
const reportRef = useRef(report); reportRef.current = report;
|
||||
const noticeRef = useRef(notice); noticeRef.current = notice;
|
||||
const noticeTime = useRef<{ issue: string; remaining: number } | null>(null);
|
||||
|
||||
const accept = useCallback((next: ConfigHealthReport) => {
|
||||
if (runtime.current.reviewing && next.status === "issues") registry.current?.mark(next);
|
||||
setReport((previous) => previous?.fingerprint === next.fingerprint && previous.status === next.status ? previous : next);
|
||||
const existing = noticeRef.current;
|
||||
if (next.status === "issues" && existing && configHealthIssueKey(next) === configHealthIssueKey(existing)) {
|
||||
// Refresh the repair token without shortening an existing notice merely
|
||||
// because an editor changed a comment or another unrelated setting.
|
||||
setNotice((previous) => previous?.fingerprint === next.fingerprint ? previous : next);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (options.reviewing && report?.status === "issues") registry.current?.mark(report);
|
||||
}, [options.reviewing, report]);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setModalOpen(Boolean(document.querySelector('[aria-modal="true"]')));
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
update();
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => { mounted.current = false; revision.current += 1; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
revision.current += 1;
|
||||
busyRef.current = false;
|
||||
setReport(null); setNotice(null); setChecking(false); setRepairing(false); setError("");
|
||||
const directory = options.configDir;
|
||||
const instance = createConfigHealthMonitor({
|
||||
read: () => invoke<ConfigHealthReport>("check_codex_config", { configDir: directory || null }),
|
||||
revision: () => revision.current,
|
||||
isVisible: () => document.visibilityState !== "hidden",
|
||||
canRead: () => !busyRef.current && runtime.current.canCheck && scope.current === directory,
|
||||
canNotify: () => runtime.current.canNotify && !modalOpenRef.current,
|
||||
onReport: accept,
|
||||
onRecovered: (next) => { registry.current?.clear(next); setNotice(null); },
|
||||
onProblem: (next) => {
|
||||
if (registry.current?.has(next)) return;
|
||||
registry.current?.mark(next);
|
||||
noticeTime.current = { issue: configHealthIssueKey(next), remaining: 30_000 };
|
||||
setNotice(next);
|
||||
},
|
||||
});
|
||||
monitor.current = instance;
|
||||
const wake = () => {
|
||||
setVisible(document.visibilityState !== "hidden");
|
||||
instance.wake();
|
||||
};
|
||||
window.addEventListener("focus", wake);
|
||||
document.addEventListener("visibilitychange", wake);
|
||||
instance.wake();
|
||||
return () => {
|
||||
instance.stop();
|
||||
if (monitor.current === instance) monitor.current = null;
|
||||
window.removeEventListener("focus", wake);
|
||||
document.removeEventListener("visibilitychange", wake);
|
||||
};
|
||||
}, [options.configDir, accept]);
|
||||
|
||||
useEffect(() => { if (options.canCheck) monitor.current?.wake(); }, [options.canCheck, options.canNotify, modalOpen]);
|
||||
|
||||
const dismiss = useCallback((hint = false) => {
|
||||
setNotice(null); noticeTime.current = null;
|
||||
if (hint) runtime.current.onHint(runtime.current.lang === "zh"
|
||||
? "稍后可到「设置 → 通用设置 → 环境与配置检查」处理。"
|
||||
: "You can check and repair this later in Settings → General → Environment & configuration check.");
|
||||
}, []);
|
||||
|
||||
const noticeVisible = Boolean(notice && report?.status === "issues"
|
||||
&& configHealthIssueKey(notice) === configHealthIssueKey(report))
|
||||
&& options.canNotify && visible && !modalOpen;
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice || !noticeVisible || repairing || checking) return;
|
||||
const issue = configHealthIssueKey(notice);
|
||||
if (noticeTime.current?.issue !== issue) noticeTime.current = { issue, remaining: 30_000 };
|
||||
const started = Date.now();
|
||||
const timer = window.setTimeout(() => dismiss(true), noticeTime.current.remaining);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
if (noticeTime.current?.issue === issue) noticeTime.current.remaining = Math.max(0, noticeTime.current.remaining - (Date.now() - started));
|
||||
};
|
||||
}, [notice, noticeVisible, repairing, checking, dismiss]);
|
||||
|
||||
const check = useCallback(async () => {
|
||||
if (busyRef.current || !runtime.current.canCheck) return;
|
||||
const directory = scope.current;
|
||||
const request = ++revision.current;
|
||||
busyRef.current = true; setChecking(true); setError("");
|
||||
const current = () => mounted.current && scope.current === directory && request === revision.current;
|
||||
try {
|
||||
const next = await invoke<ConfigHealthReport>("check_codex_config", { configDir: directory || null });
|
||||
if (current()) {
|
||||
accept(next);
|
||||
if (next.status === "issues") registry.current?.mark(next);
|
||||
if (next.status === "healthy") { registry.current?.clear(next); dismiss(); }
|
||||
}
|
||||
} catch (cause) { if (current()) setError(String(cause)); }
|
||||
finally { if (current()) { busyRef.current = false; setChecking(false); } }
|
||||
}, [accept, dismiss]);
|
||||
|
||||
const repair = useCallback(async () => {
|
||||
const before = reportRef.current ?? noticeRef.current;
|
||||
if (!before?.canRepair || busyRef.current || !runtime.current.canCheck) return;
|
||||
const directory = scope.current;
|
||||
const request = ++revision.current;
|
||||
busyRef.current = true; setRepairing(true); setError("");
|
||||
const current = () => mounted.current && scope.current === directory && request === revision.current;
|
||||
try {
|
||||
const result = await invoke<ConfigHealthRepairResult>("repair_codex_config", { configDir: before.codexDir, expectedFingerprint: before.fingerprint });
|
||||
if (!current()) return;
|
||||
dismiss(); accept(result.report);
|
||||
if (result.report.status === "healthy") registry.current?.clear(result.report);
|
||||
const zh = runtime.current.lang === "zh";
|
||||
runtime.current.onHint(result.report.status === "healthy"
|
||||
? (zh ? "配置已检查并修复,请重新打开原来的 Codex 对话。" : "Configuration checked and repaired. Reopen the Codex conversation.")
|
||||
: (zh ? "可自动处理的部分已修复,其余问题可在通用设置中查看。" : "Supported repairs are complete. Review the remaining issues in General settings."));
|
||||
runtime.current.onRepaired();
|
||||
} catch (cause) {
|
||||
if (current()) {
|
||||
dismiss(); setError(String(cause));
|
||||
runtime.current.onHint(runtime.current.lang === "zh" ? "配置未修复,请到「设置 → 通用设置」查看原因或重新检查。" : "Configuration was not repaired. Review the issue or check again in Settings → General.");
|
||||
}
|
||||
} finally { if (current()) { busyRef.current = false; setRepairing(false); } }
|
||||
}, [accept, dismiss]);
|
||||
|
||||
const openConfig = useCallback(async () => {
|
||||
try { await invoke("open_codex_config_file", { configDir: reportRef.current?.codexDir || scope.current || null }); }
|
||||
catch (cause) { if (mounted.current) setError(String(cause)); }
|
||||
}, []);
|
||||
|
||||
return { report, notice, checking, repairing, error, check, repair, dismiss, openConfig,
|
||||
noticeVisible };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createConfigHealthMonitor, createConfigHealthNoticeRegistry } from "../src/configHealthMonitor.ts";
|
||||
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((done, fail) => { resolve = done; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
const flush = async () => { await Promise.resolve(); await Promise.resolve(); };
|
||||
const issue = { code: "missing-provider", title: "供应商配置缺失", description: "选中的供应商没有对应配置。", repairable: true };
|
||||
function report(status = "issues", overrides = {}) {
|
||||
return {
|
||||
codexDir: "/fixtures/codex", fingerprint: `${status}-file`, status,
|
||||
issues: status === "issues" ? [issue] : [], canRepair: status === "issues",
|
||||
repairSummary: status === "issues" ? ["恢复选中的供应商配置"] : [],
|
||||
checkedAt: "2026-09-10T00:00:00Z", ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function harness(callbacks = {}) {
|
||||
const requests = [], applied = [], problems = [], timers = new Map();
|
||||
let timerId = 0;
|
||||
const state = { visible: true, readable: true, notifiable: true, revision: 0, now: 0 };
|
||||
const monitor = createConfigHealthMonitor({
|
||||
read: () => { const request = deferred(); requests.push(request); return request.promise; },
|
||||
onReport: (value) => { applied.push(value); callbacks.onReport?.(value); },
|
||||
onProblem: (value) => { problems.push(value); callbacks.onProblem?.(value); },
|
||||
onRecovered: callbacks.onRecovered,
|
||||
revision: () => state.revision,
|
||||
isVisible: () => state.visible,
|
||||
canRead: () => state.readable,
|
||||
canNotify: () => state.notifiable,
|
||||
now: () => state.now,
|
||||
intervalMs: 20_000,
|
||||
settleMs: 1_000,
|
||||
schedule: (callback, delay) => {
|
||||
const id = ++timerId;
|
||||
timers.set(id, { callback, delay, at: state.now + delay });
|
||||
return () => timers.delete(id);
|
||||
},
|
||||
});
|
||||
function tick() {
|
||||
assert.equal(timers.size, 1, "only one background wake should be scheduled");
|
||||
const timer = timers.values().next().value;
|
||||
timers.clear();
|
||||
state.now = timer.at;
|
||||
timer.callback();
|
||||
}
|
||||
async function finish(value, index = requests.length - 1) { requests[index].resolve(value); await flush(); }
|
||||
async function fail(index = requests.length - 1) { requests[index].reject(new Error("configuration is being replaced")); await flush(); }
|
||||
return { ...monitor, requests, applied, problems, timers, state, tick, finish, fail };
|
||||
}
|
||||
|
||||
test("healthy, missing, and unreadable reports update settings without a background warning", async () => {
|
||||
const h = harness();
|
||||
h.wake();
|
||||
for (const status of ["healthy", "healthy", "missing", "unavailable"]) {
|
||||
await h.finish(report(status));
|
||||
assert.deepEqual(h.problems, []);
|
||||
assert.equal(h.timers.values().next().value.delay, 20_000);
|
||||
h.tick();
|
||||
}
|
||||
assert.deepEqual(h.applied.map((value) => value.status), ["healthy", "healthy", "missing", "unavailable"]);
|
||||
h.stop();
|
||||
await h.finish(report());
|
||||
assert.equal(h.timers.size, 0);
|
||||
});
|
||||
|
||||
test("recovery needs a stable healthy file; a temporary deletion or empty rewrite cannot reset notification history", async () => {
|
||||
const recovered = [];
|
||||
const h = harness({ onRecovered: (value) => recovered.push(value) });
|
||||
h.wake();
|
||||
await h.finish(report());
|
||||
h.tick(); await h.finish(report("missing"));
|
||||
h.tick(); await h.finish(report("healthy", { fingerprint: "temporary-empty-file" }));
|
||||
h.tick(); await h.finish(report());
|
||||
assert.equal(recovered.length, 0);
|
||||
h.tick(); await h.finish(report("healthy"));
|
||||
h.state.now += 300; h.wake(); await h.finish(report("healthy"));
|
||||
assert.equal(recovered.length, 0);
|
||||
h.tick(); await h.finish(report("healthy"));
|
||||
assert.deepEqual(recovered.map((value) => value.fingerprint), ["healthy-file"]);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test("temporary editor errors stay silent and a stable problem needs two separated reads", async () => {
|
||||
const h = harness();
|
||||
h.wake();
|
||||
await h.finish(report());
|
||||
assert.equal(h.problems.length, 0);
|
||||
assert.equal(h.timers.values().next().value.delay, 1_000);
|
||||
h.tick();
|
||||
await h.finish(report("healthy"));
|
||||
assert.equal(h.problems.length, 0);
|
||||
h.tick();
|
||||
await h.finish(report());
|
||||
assert.equal(h.problems.length, 0, "an earlier repaired problem must not count as confirmation");
|
||||
h.tick();
|
||||
await h.finish(report());
|
||||
assert.equal(h.problems.length, 1);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test("a changing file restarts confirmation and rapid focus events do not bypass settling", async () => {
|
||||
const h = harness();
|
||||
const first = report("issues", { fingerprint: "partial-write-a" });
|
||||
const final = report("issues", { fingerprint: "final-write-b" });
|
||||
h.wake();
|
||||
await h.finish(first);
|
||||
h.state.now = 400;
|
||||
h.wake();
|
||||
await h.finish(final);
|
||||
h.state.now = 600;
|
||||
h.wake();
|
||||
await h.finish(final);
|
||||
assert.equal(h.problems.length, 0);
|
||||
assert.equal(h.timers.values().next().value.delay, 800);
|
||||
h.tick();
|
||||
await h.finish(final);
|
||||
assert.deepEqual(h.problems, [final]);
|
||||
assert.equal(h.state.now, 1_400);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test("focus and visibility wakeups coalesce into one request and discard superseded results", async () => {
|
||||
const h = harness();
|
||||
h.wake(); h.wake(); h.wake();
|
||||
assert.equal(h.requests.length, 1);
|
||||
await h.finish(report());
|
||||
assert.equal(h.applied.length, 0);
|
||||
assert.equal(h.problems.length, 0);
|
||||
assert.equal(h.requests.length, 2, "pending wakeups must produce only one new request");
|
||||
await h.finish(report("healthy"));
|
||||
assert.deepEqual(h.applied.map((value) => value.status), ["healthy"]);
|
||||
assert.equal(h.timers.size, 1);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test("provider edits and directory revisions discard stale results and restart confirmation", async () => {
|
||||
const h = harness();
|
||||
h.wake();
|
||||
h.state.revision += 1;
|
||||
await h.finish(report());
|
||||
assert.equal(h.applied.length, 0);
|
||||
h.tick();
|
||||
await h.finish(report());
|
||||
assert.equal(h.problems.length, 0);
|
||||
h.state.revision += 1;
|
||||
h.tick();
|
||||
await h.finish(report());
|
||||
assert.equal(h.problems.length, 0, "a candidate from the previous revision is not confirmation");
|
||||
h.tick();
|
||||
await h.finish(report());
|
||||
assert.equal(h.problems.length, 1);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test("hidden windows cancel scheduled reads and ignore a read that finishes while hidden", async () => {
|
||||
const h = harness();
|
||||
h.wake();
|
||||
await h.finish(report("healthy"));
|
||||
h.state.visible = false;
|
||||
h.wake();
|
||||
assert.equal(h.requests.length, 1);
|
||||
assert.equal(h.timers.size, 0);
|
||||
h.state.visible = true;
|
||||
h.wake();
|
||||
assert.equal(h.requests.length, 2);
|
||||
h.state.visible = false;
|
||||
await h.finish(report());
|
||||
assert.equal(h.applied.length, 1);
|
||||
assert.equal(h.problems.length, 0);
|
||||
assert.equal(h.timers.size, 0);
|
||||
h.state.visible = true;
|
||||
h.wake();
|
||||
await h.finish(report("healthy"));
|
||||
assert.equal(h.applied.length, 2);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test("configuration mutations block both starting and accepting background reads", async () => {
|
||||
const h = harness();
|
||||
h.state.readable = false;
|
||||
h.wake();
|
||||
assert.equal(h.requests.length, 0);
|
||||
h.state.readable = true;
|
||||
h.tick();
|
||||
h.state.readable = false;
|
||||
await h.finish(report());
|
||||
assert.equal(h.applied.length, 0);
|
||||
assert.equal(h.problems.length, 0);
|
||||
h.state.readable = true;
|
||||
h.tick();
|
||||
await h.finish(report());
|
||||
assert.equal(h.problems.length, 0);
|
||||
h.tick();
|
||||
await h.finish(report());
|
||||
assert.equal(h.problems.length, 1);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test("an active dialog can defer the toast without stopping background reports", async () => {
|
||||
const h = harness();
|
||||
h.state.notifiable = false;
|
||||
h.wake();
|
||||
await h.finish(report());
|
||||
h.tick();
|
||||
await h.finish(report());
|
||||
assert.equal(h.applied.length, 2);
|
||||
assert.equal(h.problems.length, 0);
|
||||
h.state.notifiable = true;
|
||||
h.wake();
|
||||
await h.finish(report());
|
||||
assert.equal(h.problems.length, 1);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test("transient IPC failures stay silent and break the confirmation streak", async () => {
|
||||
const h = harness();
|
||||
h.wake();
|
||||
await h.finish(report());
|
||||
h.tick();
|
||||
await h.fail();
|
||||
assert.equal(h.problems.length, 0);
|
||||
assert.equal(h.applied.length, 1);
|
||||
h.tick();
|
||||
await h.finish(report());
|
||||
assert.equal(h.problems.length, 0);
|
||||
h.tick();
|
||||
await h.finish(report());
|
||||
assert.equal(h.problems.length, 1);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test("stopping during a pending wake prevents all late reports and future timers", async () => {
|
||||
const h = harness();
|
||||
h.wake(); h.wake();
|
||||
h.stop();
|
||||
await h.finish(report());
|
||||
h.wake();
|
||||
assert.equal(h.requests.length, 1);
|
||||
assert.equal(h.applied.length, 0);
|
||||
assert.equal(h.problems.length, 0);
|
||||
assert.equal(h.timers.size, 0);
|
||||
});
|
||||
|
||||
function memoryStorage(initial) {
|
||||
const values = new Map(initial ? [["codexx.configHealth.notified.v1", initial]] : []);
|
||||
return { values, getItem: (key) => values.get(key) ?? null, setItem: (key, value) => values.set(key, value) };
|
||||
}
|
||||
|
||||
test("notice suppression survives restarts and ignores unrelated TOML edits and issue ordering", () => {
|
||||
const storage = memoryStorage();
|
||||
const original = report("issues", { issues: [issue, { ...issue, code: "invalid-wire-api", title: "接口配置错误" }] });
|
||||
const first = createConfigHealthNoticeRegistry(storage);
|
||||
assert.equal(first.has(original), false);
|
||||
first.mark(original);
|
||||
const reopened = createConfigHealthNoticeRegistry(storage);
|
||||
assert.equal(reopened.has(original), true);
|
||||
assert.equal(reopened.has({ ...original, fingerprint: "different-comment", checkedAt: "2026-09-11T00:00:00Z", issues: [...original.issues].reverse() }), true);
|
||||
assert.equal(reopened.has({ ...original, issues: [issue] }), false);
|
||||
const persisted = [...storage.values.values()].join("");
|
||||
assert.equal(persisted.includes(original.codexDir), false, "notification storage does not need raw configuration paths");
|
||||
assert.equal(persisted.includes(issue.description), false, "notification storage does not need issue details");
|
||||
});
|
||||
|
||||
test("suppression is scoped to the configuration directory and recovery permits a later warning", async () => {
|
||||
const storage = memoryStorage();
|
||||
const seen = createConfigHealthNoticeRegistry(storage);
|
||||
const notices = [];
|
||||
const h = harness({
|
||||
onReport: (value) => { if (value.status === "healthy") seen.clear(value); },
|
||||
onProblem: (value) => { if (!seen.has(value)) { seen.mark(value); notices.push(value); } },
|
||||
});
|
||||
h.wake();
|
||||
await h.finish(report());
|
||||
h.tick();
|
||||
await h.finish(report());
|
||||
h.tick();
|
||||
await h.finish(report());
|
||||
assert.equal(notices.length, 1);
|
||||
assert.equal(seen.has(report("issues", { codexDir: "/fixtures/other-codex" })), false);
|
||||
h.tick();
|
||||
await h.finish(report("healthy"));
|
||||
assert.equal(createConfigHealthNoticeRegistry(storage).has(report()), false);
|
||||
h.tick();
|
||||
await h.finish(report());
|
||||
assert.equal(notices.length, 1, "a recovered issue must still be confirmed again");
|
||||
h.tick();
|
||||
await h.finish(report());
|
||||
assert.equal(notices.length, 2);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test("registry recovery only clears its own directory and normalizes Windows path separators", () => {
|
||||
const seen = createConfigHealthNoticeRegistry(memoryStorage());
|
||||
const windows = report("issues", { codexDir: "C:\\Users\\Sample\\.codex" });
|
||||
const other = report("issues", { codexDir: "/fixtures/other-codex" });
|
||||
seen.mark(windows);
|
||||
seen.mark(other);
|
||||
assert.equal(seen.has({ ...windows, codexDir: "C:/Users/Sample/.codex" }), true);
|
||||
seen.clear({ ...windows, status: "healthy", issues: [] });
|
||||
assert.equal(seen.has(windows), false);
|
||||
assert.equal(seen.has(other), true);
|
||||
});
|
||||
|
||||
test("blocked or malformed browser storage falls back to in-memory notification suppression", () => {
|
||||
for (const storage of [
|
||||
undefined,
|
||||
{ getItem() { throw new Error("storage blocked"); }, setItem() { throw new Error("storage blocked"); } },
|
||||
memoryStorage("invalid JSON"),
|
||||
memoryStorage(JSON.stringify({ scope: "not-an-array" })),
|
||||
memoryStorage(JSON.stringify([null, {}, "bad-entry", { scope: 1, issue: [] }])),
|
||||
]) {
|
||||
const seen = createConfigHealthNoticeRegistry(storage);
|
||||
assert.equal(seen.has(report()), false);
|
||||
seen.mark(report());
|
||||
assert.equal(seen.has(report()), true);
|
||||
seen.clear(report("healthy"));
|
||||
assert.equal(seen.has(report()), false);
|
||||
}
|
||||
});
|
||||
+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 秒可检查等待提示;失败时应允许重试,安装交接后不应再次启动安装。
|
||||
|
||||
+344
-18
@@ -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;
|
||||
@@ -21,6 +23,55 @@
|
||||
let pauseUsage = false;
|
||||
let quotaMode = "dual";
|
||||
let resetCreditsMode = "available";
|
||||
let configHealthMode = new URLSearchParams(location.search).get("health") || "healthy";
|
||||
let configStateError = new URLSearchParams(location.search).has("stateError");
|
||||
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";
|
||||
const manual = configHealthMode === "syntax";
|
||||
return {
|
||||
codexDir, fingerprint: `fixture-health-${configHealthMode}`,
|
||||
status: broken ? "issues" : "healthy", checkedAt: new Date().toISOString(),
|
||||
canRepair: broken && !manual,
|
||||
issues: !broken ? [] : [{ code: manual ? "syntax" : "missing-provider", title: manual ? "配置文件格式不正确" : "旧会话的供应商配置缺失",
|
||||
description: manual ? "配置文件第 4 行附近的格式不正确,需要手动检查。" : "部分旧会话仍在使用 my_codex,但配置中已找不到这条供应商。", repairable: !manual }],
|
||||
repairSummary: broken && !manual ? ["为 my_codex 补齐配置,继续使用当前供应商 Fixture Saved Provider。", "修复前自动备份现有配置。"] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function officialToml(model) {
|
||||
return `model_provider = "openai"\nmodel = ${JSON.stringify(model || "fixture-official-model")}\n`;
|
||||
@@ -47,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,
|
||||
}]]);
|
||||
|
||||
@@ -112,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",
|
||||
@@ -181,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();
|
||||
@@ -197,8 +254,10 @@
|
||||
version: 2,
|
||||
state,
|
||||
savedProviders,
|
||||
providerOrders: Object.fromEntries(providerOrders),
|
||||
savedPrompts,
|
||||
nextOfficialId,
|
||||
failoverSettings,
|
||||
officialProfiles: Array.from(officialProfiles.values()),
|
||||
}));
|
||||
} catch {
|
||||
@@ -207,12 +266,19 @@
|
||||
}
|
||||
|
||||
localStorage.setItem("codexx.lang", "zh");
|
||||
localStorage.setItem("codexx.startupWizardSeen", "1");
|
||||
const firstRunFixture = new URLSearchParams(location.search).has("firstRun");
|
||||
if (firstRunFixture && sessionStorage.getItem("codexx.fixture.firstRunStarted") !== "1") {
|
||||
localStorage.removeItem("codexx.startupWizardSeen");
|
||||
sessionStorage.setItem("codexx.fixture.firstRunStarted", "1");
|
||||
} else if (!firstRunFixture) localStorage.setItem("codexx.startupWizardSeen", "1");
|
||||
localStorage.removeItem("codexx.activeProviderId");
|
||||
localStorage.removeItem("codexx.promptCategories.v1");
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
startupWizardSeen: localStorage.getItem("codexx.startupWizardSeen"),
|
||||
transferMode, lastTransfer,
|
||||
failoverSettings: clone(failoverSettings), failoverMode,
|
||||
pendingSync: pending.sync.length,
|
||||
pendingDetail: pending.detail.length,
|
||||
pendingQuota: pending.quota.length,
|
||||
@@ -232,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),
|
||||
};
|
||||
}
|
||||
@@ -317,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);
|
||||
@@ -665,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);
|
||||
@@ -679,8 +838,71 @@
|
||||
|
||||
async function dispatch(command, args) {
|
||||
switch (command) {
|
||||
case "get_codex_state": return clone(state);
|
||||
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("配置已变更,请重新检查。");
|
||||
if (!healthReport().canRepair) throw new Error("此配置需要手动检查。");
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
configHealthMode = "healthy";
|
||||
configStateError = false;
|
||||
return { report: healthReport(), changed: true, backupId: "fixture-backup" };
|
||||
}
|
||||
case "open_codex_config_file": return null;
|
||||
case "get_codex_state":
|
||||
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":
|
||||
@@ -705,15 +927,35 @@
|
||||
case "get_about_info": return {
|
||||
appVersion: "0.3.15", codexVersion: "fixture-cli", codexDir,
|
||||
projectUrl: "https://project.example.test/", githubRepo: "fixture/example",
|
||||
nativeUpdaterSupported: false,
|
||||
};
|
||||
case "get_startup_diagnostics": return {
|
||||
codexDir, needsManualSelect: false, summary: "Fixture ready", items: [],
|
||||
nativeUpdaterSupported: updaterEnabled,
|
||||
};
|
||||
case "get_startup_diagnostics": {
|
||||
if (new URLSearchParams(location.search).has("environmentError")) throw new Error("Fixture: environment check unavailable");
|
||||
return {
|
||||
codexDir, needsManualSelect: false, summary: "Fixture ready",
|
||||
items: [
|
||||
{ key: "home", label: "Codex 配置目录", status: "ok", message: "已找到", path: codexDir },
|
||||
{ key: "config", label: "配置文件", status: "ok", message: "已找到", path: `${codexDir}/config.toml` },
|
||||
{ key: "auth", label: "登录信息", status: "ok", message: "已找到", path: `${codexDir}/auth.json` },
|
||||
{ key: "sessions", label: "会话记录", status: "ok", message: "已找到", path: `${codexDir}/state_5.sqlite` },
|
||||
],
|
||||
};
|
||||
}
|
||||
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");
|
||||
@@ -796,20 +1038,40 @@
|
||||
case "test_provider_connection": return { ok: true, status: 200, message: "Fixture connection OK", durationMs: 1 };
|
||||
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: 0, sessionMetaCount: 0,
|
||||
mismatchedRollouts: 0, mismatchedSessionMeta: 0, sqliteDbs: 1, sqliteThreads: 0,
|
||||
topLevelThreads: 0, subagentThreads: 0, mismatchedThreads: 0, mismatchedSessions: 0,
|
||||
needsSync: false, scanComplete: true, scanFailures: [], warnings: [], sessions: [],
|
||||
};
|
||||
case "get_skills_mcp_state": return {
|
||||
codexDir, codexSkillsDir: `${codexDir}/skills`, disabledSkillsDir: `${codexDir}/disabled-skills`,
|
||||
skills: [], mcpServers: [], warnings: [],
|
||||
codexDir, targetProvider: state.modelProvider, rolloutFiles: 2, sessionMetaCount: 2,
|
||||
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);
|
||||
case "preview_existing_skills_mcp": return existingImportAvailable
|
||||
? { skills: [fixtureSkill("research", "资料整理")], mcpServers: [fixtureMcp("files", "本地文件"), fixtureMcp("search", "网页检索")], warnings: ["Fixture:一条无效配置已跳过。"] }
|
||||
: { skills: [], mcpServers: [], warnings: [] };
|
||||
case "import_existing_skills_mcp":
|
||||
if (existingImportAvailable) {
|
||||
transferState.skills.push(fixtureSkill("research", "资料整理"));
|
||||
transferState.mcpServers.push(fixtureMcp("files", "本地文件"), fixtureMcp("search", "网页检索"));
|
||||
existingImportAvailable = false;
|
||||
return { importedSkills: 1, importedMcp: 2, message: "已导入 1 个 Skill、2 个 MCP", state: clone(transferState) };
|
||||
}
|
||||
return { importedSkills: 0, importedMcp: 0, message: "没有需要新导入的内容", state: clone(transferState) };
|
||||
case "export_codex_sessions":
|
||||
case "export_skills_mcp_archive":
|
||||
case "import_skills_mcp_archive": {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
if (transferMode === "cancel") return null;
|
||||
if (transferMode === "error") throw new Error("Fixture:无法写入所选位置,请选择其他位置。");
|
||||
lastTransfer = { command, kind: args.kind || null, sessionIds: args.sessionIds || null };
|
||||
if (command === "export_codex_sessions") return { path: `/fixture/exports/${args.suggestedName}.${args.sessionIds.length === 1 ? "md" : "zip"}`, exportedSessions: args.sessionIds.length, failedSessions: 0, warnings: [] };
|
||||
if (command === "export_skills_mcp_archive") return { path: `/fixture/exports/${args.kind}.zip`, exportedSkills: args.kind === "skills" ? transferState.skills.length : 0, exportedMcp: args.kind === "mcp" ? transferState.mcpServers.length : 0 };
|
||||
return { importedSkills: 0, importedMcp: 0, message: "内容已存在,无需重复导入", state: clone(transferState) };
|
||||
}
|
||||
case "open_url": return undefined;
|
||||
default: throw new Error(`Fixture has no handler for command: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener(event, eventId) { eventListeners.delete(eventId); } };
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke,
|
||||
transformCallback(callback, once = false) {
|
||||
@@ -828,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),
|
||||
@@ -852,6 +1118,31 @@
|
||||
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", "文件操作失败"]]) {
|
||||
const option = document.createElement("option"); option.value = value; option.textContent = label; transferSelect.append(option);
|
||||
}
|
||||
transferSelect.addEventListener("change", () => { transferMode = transferSelect.value; render(); }); controls.append(transferSelect);
|
||||
for (const [text, mode] of [["Fixture:配置正常", "healthy"], ["Fixture:缺失供应商配置", "missing"], ["Fixture:配置语法错误", "syntax"]]) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button"; button.textContent = text;
|
||||
button.addEventListener("click", () => { configHealthMode = mode; window.dispatchEvent(new Event("focus")); });
|
||||
controls.append(button);
|
||||
}
|
||||
for (const [text, kind, fail] of [
|
||||
["Fixture:完成模板同步", "sync", false],
|
||||
["Fixture:使模板同步失败", "sync", true],
|
||||
@@ -909,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";
|
||||
@@ -940,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,254 @@
|
||||
|
||||
日志不得包含 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 修复。
|
||||
发布说明沿用四条面向用户的更新,根目录、桌面、Tauri、Cargo 清单及锁文件统一为 0.3.18。
|
||||
- 发布前沿用已完成的 490 项后端、45 项前端回归与本地 App 验证,版本更改后复核类型、构建和锁文件。
|
||||
本次停止继续调查 Codex 前端临时显示 Luna 的问题,不改历史会话或官方模型偏好。
|
||||
- 官网源码和本地测试截图继续留在本机,未纳入发布提交;由版本标签触发现有四平台构建、签名与发布校验。
|
||||
|
||||
## 2026-09-17:模型推理等级与第三方 WebSocket 回退
|
||||
|
||||
- 所有第三方模型映射统一生成完整八档菜单(none/minimal/low/medium/high/xhigh/max/ultra),默认 high。
|
||||
用户启用供应商后直接在 Codex 中选择,无需在 Codex-X 逐模型配置,不增加档位选择表单或厂商映射说明。
|
||||
菜单可选不代表所有上游都实现了八种独立强度,实际效果仍由接口和 Codex 决定。已有模型记录无需增字段。
|
||||
目录按内容生成新文件,不覆盖旧目录或模型缓存。
|
||||
- 自定义目录补齐 supported_reasoning_levels 和 default_reasoning_level;有档位时为旧 Codex 保留
|
||||
supports_reasoning_summaries=true,同时关闭摘要参数、默认摘要设 none。Codex 0.153/0.154 已将
|
||||
effort 独立发送;ultra 在客户端可能折算成 max,并不保证独立的上游档位。
|
||||
参考 CC Switch 06082e1 的 CodexFormFields/codex_config 实现及 DeepSeek 官方 Responses/Codex 文档,
|
||||
没有复制上游代码、缓存提示词或伪造 GPT 的其他能力。
|
||||
- WSS 405 根因是第三方草稿继承了官方 custom 表中的 supports_websockets=true。传输能力现在归属
|
||||
对应端点:新建(包括携带同 URL 的继承模板)默认 false;编辑自身同端点模板保留明确值,换地址重置。
|
||||
api.deepseek.com 精确匹配禁用 WS,独立代理域名不干预;官方登录仍为 true,不改全局 WS feature。
|
||||
已保存 DeepSeek 重新启用也会修正,当前配置通过配置检查提示并由用户点击后备份修复。
|
||||
- 验证覆盖完整档位元数据与默认值、旧记录、供应商官方/第三方切换、同端点新建与编辑区分,
|
||||
以及现有错误配置的只读检查和可选修复。模型目录刷新需要重新启用供应商并重新打开 Codex。
|
||||
最终 490 项后端测试、45 项前端测试、类型检查与 macOS 构建通过;本地 App 已备份、校验并替换启动,
|
||||
只保留一份应用注册。未使用用户密钥请求上游,也未重启用户的 Codex 客户端。尚未推送 GitHub。
|
||||
|
||||
## 2026-09-12:会话导出、Skills/MCP 归档与大日志统计
|
||||
|
||||
- 会话列表新增单条 Markdown 导出和多选 ZIP 导出,只包含明确选择的会话。复用活动 SQLite 与权威
|
||||
rollout 路径,验证存储根、普通文件和 session_meta.id,支持非标准文件名;正文按角色与事件类型筛选,
|
||||
过滤系统/工具/推理记录,以计数方式消除 event_msg 与 response_item 镜像,同时保留重复对话轮次。
|
||||
图片/音频使用占位,记录按行选择解析。单条记录仍有 64 MiB 保护上限,损坏或过大记录会报告部分导出。
|
||||
- Skills/MCP 使用带版本 manifest 的 ZIP,保留配置、文件权限、空目录、启用状态和备注;当前标签页可导出
|
||||
全部内容,同一 ZIP 入口识别普通 Skill 包与 Codex-X 归档。本地 ZIP 取消压缩与展开后的 20 MiB 大小上限,
|
||||
使用 rfd 原生文件窗口和 Rust 文件流,避免大文件在前端 ArrayBuffer/整数数组及 IPC 中多次复制。
|
||||
本地归档可能包含 MCP 连接凭据,导出入口有保管提示;不上传归档或会话。
|
||||
- ZIP 路径、重复路径、链接与特殊文件校验保留;导入预先检查所有同名冲突,不覆盖不同内容。
|
||||
MCP 配置与数据库变更共同回滚,失败移除本次创建的 Skill;导出临时文件使用受限权限、目标快照校验
|
||||
和原子替换,禁止写回应用数据目录。用户取消文件选择时不执行读写。
|
||||
- 导入已有的假 stdio 项来自无效/空 MCP 配置的默认展示;候选现在必须具备有效 command 或 URL,
|
||||
预览与导入复用同一验证与去重路径,已存在的实时配置不再计入新增。兼容包装 JSON,正确处理
|
||||
config.toml 中 enabled=false 的条目。暗色弹窗补齐统计格子、列表文字、边框及警告区域的颜色。
|
||||
- 用量扫描将 2 MiB 整行跳过改为 64 KiB 缓冲快路径与超长行流式反序列化,跳过大正文/图片字段,
|
||||
仍读取 token_count、模型与父子会话信息;保留增量偏移、扫描预算、坏行恢复和尾行补写规则。
|
||||
- 验证:479 项 Rust 测试、45 项前端测试、类型检查和 macOS 构建通过,包含 >20 MiB ZIP、归档往返、
|
||||
冲突与错误回滚、导出正文去重、非常规会话路径,以及 >2 MiB 图片/工具/计费事件等回归。
|
||||
浏览器确认暗色导入弹窗、无新增提示、分标签导出、单/多会话导出与取消操作;本机确认原生打开/保存
|
||||
窗口可正常取消,原先的四个已有 MCP 不再被提示重新导入。本地 App 已替换,旧 App 和构建副本均经
|
||||
ZIP/二进制校验后归档,仅保留一份应用注册。尚未推送 GitHub。
|
||||
|
||||
## 2026-09-10:后台配置检查与可选修复
|
||||
|
||||
- 后续界面调整:将设置中的首次向导入口与配置检查卡片合并为同高的一行「环境与配置检查」。
|
||||
行内仅保留配置状态和检查按钮;环境详情、配置问题和修复方案统一放进原检查窗口。
|
||||
首次安装自动打开,普通手动检查不删除或重置首次使用标记;后台静默检查保持独立。
|
||||
环境检测加载或失败时窗口仍可打开,配置检查/修复继续可用;修改目录后需重新检查再提供修复,
|
||||
避免把旧目录结果误用于新目录。查看过的问题不再紧接着重复弹出后台提醒。
|
||||
- 新增独立的只读检查 IPC;窗口可见且未编辑/写入配置时后台检查,回到窗口时唤醒。
|
||||
不依赖 get_codex_state 成功,也不占用供应商操作的 loading 状态。稳定错误需间隔至少 1 秒确认两次。
|
||||
同一目录的同一问题保存提醒记录;30 秒提醒支持直接修复和进入通用设置,忽略后提示设置入口。
|
||||
弹窗、隐藏窗口和其他通知期间暂停提醒计时;无关配置编辑更新修复指纹但不缩短剩余时间。
|
||||
临时缺失/空文件不会直接清除提醒记录,正常配置也经稳定确认后才允许以后再次提醒。
|
||||
- 检查 TOML 语法和供应商表、名称、选择标识、认证开关及协议字段,保留 Codex 的合法默认值。
|
||||
依据 [Codex 配置说明](https://learn.chatgpt.com/docs/config-file/config-reference) 和
|
||||
[rust-v0.153.4 配置校验](https://github.com/openai/codex/blob/rust-v0.153.4/codex-rs/config/src/config_toml.rs):
|
||||
name 对普通自定义供应商必需,省略 base_url、wire_api、认证/WebSocket 开关本身不是错误。
|
||||
不把 Chat Completions 协议盲改为 Responses;无法确认的问题提供查看配置入口。
|
||||
- 旧会话恢复可以使用数据库中的 model_provider,所以根配置正确时也要检查历史引用。
|
||||
只查询活动 SQLite 中最近 10,000 条记录的不同标识,不读取会话正文;有效的顶层 profile 配置可解释
|
||||
相应引用,避免误报。仅当缺失一个引用且有唯一完整来源时提供补齐方案,提示明确说明沿用哪个供应商。
|
||||
多个来源或无法确定时不猜测,不改数据库、auth、模型或其他配置文件。
|
||||
- 修复只在用户点击后执行:备份、文件锁、配置/认证判断/会话引用/profile 指纹复核及原子写入。
|
||||
外部修改使旧方案失效;符号链接配置不覆盖。错误提示不包含源文件行或凭据。
|
||||
这是供应商相关的有限检查,不宣称完整验证 Codex 所有扩展设置。
|
||||
- 验证:445 项 Rust 测试、45 项前端测试、类型检查及构建通过。浏览器验证首页读取失败仍可进入设置修复,
|
||||
提醒到期后供应商可操作,额度弹窗期间提醒隐藏,关闭提醒后的设置提示可见,手动问题无自动修复按钮。
|
||||
本地 macOS App 已构建、签名校验、替换并启动;旧安装与构建副本均经完整 ZIP/二进制校验后归档,
|
||||
Launch Services 只注册 Applications 内的一份安装。此次尚未发布到 GitHub。
|
||||
|
||||
## 2026-09-09:v0.3.17 发布准备
|
||||
|
||||
- 将已完成本地验证的官方账号额度/重置次数、套餐徽标、主流预设、模型映射与供应商编辑修复整理为 v0.3.17。
|
||||
|
||||
@@ -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.17",
|
||||
"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