mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
feat(proxy): warn when Codex clients hold an old Stack model list
Codex's app-server reads the model catalog once at startup and keeps it, while config.toml is re-read per request. Since 0.159 the codex CLI talks to a managed daemon that only restarts on upgrade, so reopening codex after a Stack change still shows the old model list; the desktop app and editor extensions have the same problem with their own app-servers. In Codex Stack mode the provider list now shows a banner when a running Codex client started before the current catalog was written: - CLI daemon: a button restarts it through Codex's own `codex app-server daemon restart`, after a confirmation that running tasks will be interrupted. Nothing runs when the daemon is not up. - Desktop app / editor extensions: the banner explains how to fully quit and reopen them; CC Switch does not restart them. Staleness is judged against a device-local history of catalog generations (codex-catalog-history.json) rather than file times: an unchanged catalog is not rewritten, and quitting CC Switch removes the catalog pointer and restores it on the next start, so a daemon that survived a CC Switch restart still has the current catalog. Every Codex write and startup records the catalog a newly started Codex would read; processes that started before the oldest recorded generation count as stale. Processes are read with ps on macOS and Linux only; Windows shows no banner for now.
This commit is contained in:
1 parent
5990d144c3
commit
52e0fa8a6c
20 files changed
+1327
-8
No files matched your search
@@ -95,14 +95,26 @@ pub fn get_direct_provider(
|
||||
crate::mode::controller::direct_provider_id(state.inner(), &app).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Stack 模型:名单里的每一家和它发布的模型 id
|
||||
/// Stack 模型:名单里的每一家和它发布的模型 id,以及还在用旧模型列表的 Codex 客户端
|
||||
#[tauri::command]
|
||||
pub fn get_proxy_stack(
|
||||
pub async fn get_proxy_stack(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
) -> Result<crate::mode::stack::StackView, String> {
|
||||
let app = require_proxy_app(&app_type)?;
|
||||
crate::mode::controller::stack_views(state.inner(), &app)
|
||||
crate::mode::controller::stack_view_with_clients(state.inner(), &app).await
|
||||
}
|
||||
|
||||
/// 重启 Codex 的托管守护进程(`codex` TUI 连的那个),让它重读模型目录。会中断守护进程里
|
||||
/// 正在运行的任务,只在用户确认之后调。
|
||||
#[tauri::command]
|
||||
pub async fn restart_codex_app_server_daemon(
|
||||
) -> Result<crate::services::provider::codex_client_catalog::RestartOutcome, String> {
|
||||
crate::services::provider::codex_direct::off_runtime(
|
||||
crate::services::provider::codex_client_catalog::restart_daemon,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
|
||||
/// Stack 模型:把一家加入或移出名单(`enabled` 是目标值)。成功时返回客户端看不到或看不全
|
||||
|
||||
@@ -1586,6 +1586,7 @@ pub fn run() {
|
||||
commands::switch_proxy_provider,
|
||||
commands::get_proxy_stack,
|
||||
commands::set_proxy_stack_member,
|
||||
commands::restart_codex_app_server_daemon,
|
||||
// Proxy failover commands
|
||||
commands::get_provider_health,
|
||||
commands::reset_circuit_breaker,
|
||||
|
||||
@@ -550,6 +550,15 @@ pub fn live_catalog_is_foreign(config_text: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// live 的 `model_catalog_json` 指向 CC Switch 生成的目录:新启动的 Codex 读的是它。
|
||||
pub fn live_catalog_is_ours(config_text: &str) -> bool {
|
||||
config_text.parse::<DocumentMut>().ok().is_some_and(|doc| {
|
||||
doc.get(MODEL_CATALOG_JSON)
|
||||
.and_then(Item::as_str)
|
||||
.is_some_and(is_cc_switch_catalog)
|
||||
})
|
||||
}
|
||||
|
||||
/// 行里自己指定的模型目录指针(投影的 `top` 只收不是 CC Switch 的指针)。它和独有字段
|
||||
/// 一样跟着这一家走:切走时 live 里的值还相同就删(见 [`CodexConfigPatch::outgoing`]),
|
||||
/// 否则第 1 步会把它当成用户的指针留下,之后每一家都用它的模型目录。
|
||||
|
||||
@@ -31,6 +31,7 @@ use crate::live::project::claude::{
|
||||
use crate::live::project::gemini::GeminiProjection;
|
||||
use crate::live::project::grok::GrokProjection;
|
||||
use crate::provider::Provider;
|
||||
use crate::services::provider::codex_client_catalog;
|
||||
use crate::services::provider::codex_direct::{self, Owner};
|
||||
use crate::services::provider::codex_official_models;
|
||||
use crate::services::provider::{claude_direct, gemini_direct, grok_direct};
|
||||
@@ -1008,9 +1009,40 @@ pub fn stack_views(state: &AppState, app: &AppType) -> Result<StackView, String>
|
||||
active: mode.is_proxy() && stack.enabled,
|
||||
members: stack::member_views(&members),
|
||||
notice,
|
||||
stale_clients: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`stack_views`] 再加上 Codex 客户端是不是还在用旧的模型列表(要读进程表,放到阻塞线程池
|
||||
/// 里)。路由那家自己管理目录、或者用户自己指定了目录时,Stack 模型本来就不发布或不生效,重启
|
||||
/// 也看不到,已经有 `notice` 说明,不再查。
|
||||
pub async fn stack_view_with_clients(state: &AppState, app: &AppType) -> Result<StackView, String> {
|
||||
let mut view = stack_views(state, app)?;
|
||||
if matches!(app, AppType::Codex)
|
||||
&& view.active
|
||||
&& !matches!(view.notice, Some("routeOwnsCatalog" | "configOwnsCatalog"))
|
||||
&& codex_publishes_stack_models(state, &settled_stack(app)?)
|
||||
{
|
||||
view.stale_clients = codex_direct::off_runtime(|| {
|
||||
codex_client_catalog::stale_clients(&DeviceStore::for_device())
|
||||
})
|
||||
.await
|
||||
.map_err(err)?;
|
||||
}
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
/// Codex 接着代理,名单里有要发布的 Stack 模型。
|
||||
fn codex_publishes_stack_models(state: &AppState, stack: &StackState) -> bool {
|
||||
attached_route(state, &AppType::Codex)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some_and(|(_, route)| {
|
||||
stack::published_members(&state.db, &AppType::Codex, stack, Some(&route.id))
|
||||
.is_ok_and(|published| !published.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
/// Codex 在 Stack 模式下有要发布的 Stack 模型,客户端却看不到或看不全:路由那家自己管理模型
|
||||
/// 目录文件(Stack 模型不发布);`config.toml` 里用户自己指定了模型目录(生成的目录不生效);
|
||||
/// 或者官方做默认、最近一次写目录时没拿到官方列表。
|
||||
@@ -1224,6 +1256,8 @@ pub async fn startup(state: &AppState) {
|
||||
}
|
||||
// 接上失败退回直连的应用可能已经把代理拉起来了。
|
||||
stop_server_if_unused(state).await;
|
||||
// 记一次新启动的 Codex 会读到的目录:兜住启动时补完的操作和 CC Switch 没开时的外部修改。
|
||||
codex_client_catalog::observe(&DeviceStore::for_device());
|
||||
}
|
||||
|
||||
async fn startup_app(state: &AppState, app: &AppType) -> Result<(), String> {
|
||||
@@ -5348,6 +5382,121 @@ model_provider = "c"
|
||||
assert_eq!(notice, None);
|
||||
}
|
||||
|
||||
/// 假的进程表和时钟(见 `codex_client_catalog::Env`),结束时换回真的。
|
||||
struct FakeClients {
|
||||
now_ms: Arc<std::sync::atomic::AtomicU64>,
|
||||
table: Arc<std::sync::Mutex<String>>,
|
||||
}
|
||||
|
||||
impl FakeClients {
|
||||
const START_MS: u64 = 1_800_000_000_000;
|
||||
|
||||
fn install() -> Self {
|
||||
let now_ms = Arc::new(std::sync::atomic::AtomicU64::new(Self::START_MS));
|
||||
let table = Arc::new(std::sync::Mutex::new(String::new()));
|
||||
let (clock, rows) = (now_ms.clone(), table.clone());
|
||||
codex_client_catalog::set_test_env(codex_client_catalog::Env {
|
||||
process_table: Box::new(move || Some(rows.lock().unwrap().clone())),
|
||||
now_ms: Box::new(move || clock.load(std::sync::atomic::Ordering::SeqCst)),
|
||||
restart: Box::new(|_| Err("not in tests".to_string())),
|
||||
});
|
||||
Self { now_ms, table }
|
||||
}
|
||||
|
||||
/// 桌面版的 app-server,已经跑了 `etime`(`ps` 的格式)。
|
||||
fn desktop_running_for(&self, etime: &str) {
|
||||
*self.table.lock().unwrap() = format!(
|
||||
"62347 {etime} /Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex -c features.code_mode_host=true app-server"
|
||||
);
|
||||
}
|
||||
|
||||
fn advance(&self, ms: u64) {
|
||||
self.now_ms
|
||||
.fetch_add(ms, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FakeClients {
|
||||
fn drop(&mut self) {
|
||||
codex_client_catalog::reset_test_env();
|
||||
}
|
||||
}
|
||||
|
||||
async fn stale_clients_of(state: &AppState) -> Option<codex_client_catalog::StaleClients> {
|
||||
stack_view_with_clients(state, &AppType::Codex)
|
||||
.await
|
||||
.expect("stack view")
|
||||
.stale_clients
|
||||
}
|
||||
|
||||
/// 桌面版在目录变化之前启动:Stack 视图带上 `staleClients`;之后启动的不算旧。路由模式、
|
||||
/// 路由那家自己管理目录(Stack 模型本来就不发布)时不查。
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn codex_stack_view_reports_clients_on_an_old_catalog() {
|
||||
let _home = Home::new();
|
||||
let clients = FakeClients::install();
|
||||
seed_codex("", None);
|
||||
clients.desktop_running_for("10:00");
|
||||
let state = state_with(AppType::Codex, &codex_stack_rows(), "a").await;
|
||||
enter(&state, &AppType::Codex, true).await.expect("enter");
|
||||
set_codex_member(&state, "deepseek", true).await;
|
||||
assert_eq!(
|
||||
stale_clients_of(&state).await,
|
||||
Some(codex_client_catalog::StaleClients {
|
||||
daemon: false,
|
||||
others: true
|
||||
})
|
||||
);
|
||||
// 普通的 Stack 视图不读进程表。
|
||||
assert_eq!(
|
||||
stack_views(&state, &AppType::Codex).unwrap().stale_clients,
|
||||
None
|
||||
);
|
||||
|
||||
// 桌面版重开之后启动:读到的正是现在这份目录。
|
||||
clients.advance(10_000);
|
||||
clients.desktop_running_for("00:05");
|
||||
assert_eq!(stale_clients_of(&state).await, None);
|
||||
|
||||
// 目录又变了(再加一家):刚才那个桌面版又旧了。
|
||||
clients.advance(10_000);
|
||||
set_codex_member(&state, "zhipu", true).await;
|
||||
clients.desktop_running_for("00:15");
|
||||
assert!(stale_clients_of(&state).await.is_some());
|
||||
|
||||
// 换成路由模式:不是 Stack 模式,不查。
|
||||
enter(&state, &AppType::Codex, false)
|
||||
.await
|
||||
.expect("routing");
|
||||
clients.desktop_running_for("10:00");
|
||||
assert_eq!(stale_clients_of(&state).await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn codex_route_with_its_own_catalog_skips_the_client_check() {
|
||||
let _home = Home::new();
|
||||
let clients = FakeClients::install();
|
||||
seed_codex("", None);
|
||||
clients.desktop_running_for("10:00");
|
||||
let [_, deepseek, zhipu] = codex_stack_rows();
|
||||
let route = codex_native(
|
||||
"a",
|
||||
"https://a.example/v1",
|
||||
"model_catalog_json = \"/opt/team/models.json\"\n",
|
||||
None,
|
||||
);
|
||||
let state = state_with(AppType::Codex, &[route, deepseek, zhipu], "a").await;
|
||||
enter(&state, &AppType::Codex, true).await.expect("enter");
|
||||
set_codex_member(&state, "deepseek", true).await;
|
||||
let view = stack_view_with_clients(&state, &AppType::Codex)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(view.notice, Some("routeOwnsCatalog"));
|
||||
assert_eq!(view.stale_clients, None);
|
||||
}
|
||||
|
||||
/// 用户直接在 config.toml 里指定的模型目录:写入时照留,生成的目录不生效,同样要提示。
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::live::engine::DeviceStore;
|
||||
use crate::live::project::claude::{env_string, has_one_m_marker, ONE_M_MARKER_FOR_CLIENT};
|
||||
use crate::provider::{ClaudeStackModel, Provider};
|
||||
use crate::proxy::model_mapper::strip_one_m_suffix_for_upstream;
|
||||
use crate::services::provider::codex_client_catalog::StaleClients;
|
||||
|
||||
use super::state::{self, StackState};
|
||||
|
||||
@@ -596,6 +597,9 @@ pub struct StackView {
|
||||
/// 暂不可用。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notice: Option<&'static str>,
|
||||
/// Codex 客户端还在用旧的模型列表(启动时读的目录),Stack 模型看不到,要重启才行。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stale_clients: Option<StaleClients>,
|
||||
}
|
||||
|
||||
pub fn member_views(members: &[Member]) -> Vec<StackMemberView> {
|
||||
|
||||
@@ -0,0 +1,760 @@
|
||||
//! Codex 客户端是不是还在用旧的模型目录,以及重启 Codex 的托管守护进程。
|
||||
//!
|
||||
//! Codex 的 app-server 只在启动时读一次模型目录(codex-rs `app-server/src/model_catalog.rs`:
|
||||
//! 「retained startup model catalog」),之后每个请求重读 `config.toml`:路由跟着变,模型列表
|
||||
//! 不变。0.159 起 `codex` TUI 默认连一个托管守护进程(`codex app-server --managed-daemon`),
|
||||
//! 它只在 Codex 升级时重启,关掉再开 `codex` 不会重读;桌面版、编辑器插件自带的 app-server
|
||||
//! 也要整个重开才会重读。
|
||||
//!
|
||||
//! 判断一个进程读到的是哪份目录,不能比文件时间:内容没变时引擎不重写文件,时间不动;退出
|
||||
//! CC Switch 时撤掉目录指针、下次启动再写回,时间变了,早先启动的进程读到的却正是现在这份。
|
||||
//! 所以记下目录的代次:从哪个时刻起,新启动的 Codex 会读到哪份目录([`HISTORY_FILENAME`])。
|
||||
//! 一个进程读到的,是它启动之前开始的最后一代。
|
||||
//!
|
||||
//! 进程只看不动:用户在界面上确认之后,才调 Codex 自己的 `codex app-server daemon restart`。
|
||||
//! 桌面版和编辑器插件不替用户重启,只提示彻底退出再开。进程表只在 macOS、Linux 上读(`ps`),
|
||||
//! Windows 上看不到进程,不出提示。
|
||||
|
||||
use std::process::Output;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::codex_config::{get_codex_config_dir, get_codex_model_catalog_path};
|
||||
use crate::live::engine::{sha256_hex, DeviceStore};
|
||||
use crate::live::project::codex::live_catalog_is_ours;
|
||||
|
||||
use super::codex_direct::read_config_text;
|
||||
|
||||
pub(crate) const HISTORY_FILENAME: &str = "codex-catalog-history.json";
|
||||
/// 最多记这么多代。更早启动的进程判断不了,按旧的算。
|
||||
const HISTORY_LIMIT: usize = 32;
|
||||
/// 进程的启动时刻只精确到秒(`ps` 的 etime),每一代又是写完之后才记下的:启动时刻离一代的
|
||||
/// 开始不到这么久,就当它读到的是上一代。拿不准时宁可多提示一次。
|
||||
const MARGIN_MS: u64 = 2_000;
|
||||
/// 新启动的 Codex 不读 CC Switch 的目录(没有目录指针,或者指向别人的目录)。
|
||||
const NO_CATALOG: &str = "none";
|
||||
/// 守护进程停机宽限期的缺省值和上限(codex-rs `app-server-daemon/src/settings.rs`)。
|
||||
const DEFAULT_SHUTDOWN_GRACE_SECS: u64 = 60;
|
||||
const MAX_SHUTDOWN_GRACE_SECS: u64 = 300;
|
||||
/// 宽限期之外,起新进程、等它就绪的余量(codex-rs 的 `OPERATION_LOCK_TIMEOUT` 也是在宽限期上
|
||||
/// 加 75 秒)。
|
||||
const RESTART_MARGIN: Duration = Duration::from_secs(75);
|
||||
|
||||
/// 一代目录:从 `since_ms`(Unix 毫秒)起,新启动的 Codex 读到的目录。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Generation {
|
||||
since_ms: u64,
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
/// 还在用旧模型列表的 Codex 客户端(给前端)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StaleClients {
|
||||
/// 托管守护进程(`codex` TUI 连的那个):可以替用户重启。
|
||||
pub daemon: bool,
|
||||
/// 其余 app-server(桌面版、编辑器插件):要用户自己彻底退出再开。
|
||||
pub others: bool,
|
||||
}
|
||||
|
||||
/// 重启守护进程的结果(给前端)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RestartOutcome {
|
||||
Restarted,
|
||||
/// 守护进程没在跑,什么都没做:`restart` 会替用户起一个新的,下次开 `codex` 时它自己会起。
|
||||
NotRunning,
|
||||
}
|
||||
|
||||
/// 正在跑的 app-server 的启动时刻(Unix 毫秒)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
struct AppServers {
|
||||
daemon: Option<u64>,
|
||||
others: Vec<u64>,
|
||||
}
|
||||
|
||||
/// 外部依赖:进程表、时钟、重启命令。测试里换成假的。
|
||||
pub(crate) struct Env {
|
||||
/// `ps -Ao pid=,etime=,command=` 的输出;读不到(或在 Windows 上)是 `None`。
|
||||
pub process_table: Box<dyn Fn() -> Option<String> + Send + Sync>,
|
||||
/// Unix 毫秒。
|
||||
pub now_ms: Box<dyn Fn() -> u64 + Send + Sync>,
|
||||
/// 执行 `codex app-server daemon restart`,参数是超时。
|
||||
pub restart: Box<dyn Fn(Duration) -> Result<Output, String> + Send + Sync>,
|
||||
}
|
||||
|
||||
impl Env {
|
||||
fn real() -> Self {
|
||||
Self {
|
||||
process_table: Box::new(read_process_table),
|
||||
now_ms: Box::new(|| {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|elapsed| elapsed.as_millis() as u64)
|
||||
.unwrap_or_default()
|
||||
}),
|
||||
restart: Box::new(run_restart),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn env_slot() -> &'static Mutex<Option<Arc<Env>>> {
|
||||
static SLOT: OnceLock<Mutex<Option<Arc<Env>>>> = OnceLock::new();
|
||||
SLOT.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
fn env() -> Arc<Env> {
|
||||
let mut slot = env_slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
slot.get_or_insert_with(|| Arc::new(Env::real())).clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_test_env(env: Env) {
|
||||
*env_slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Arc::new(env));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reset_test_env() {
|
||||
*env_slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn read_process_table() -> Option<String> {
|
||||
// `-ww`:输出不是终端时 Linux 的 procps 也会截断命令行。
|
||||
let output = std::process::Command::new("ps")
|
||||
.args(["-ww", "-Ao", "pid=,etime=,command="])
|
||||
.env("LC_ALL", "C")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.map_err(|error| log::debug!("读取进程表失败: {error}"))
|
||||
.ok()?;
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn read_process_table() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn run_restart(timeout: Duration) -> Result<Output, String> {
|
||||
// 重启的必须是读 CC Switch 写的这份配置的守护进程(配置目录可能被覆盖到别处)。
|
||||
let codex_dir = get_codex_config_dir();
|
||||
let extra_env = [("CODEX_HOME", codex_dir.to_string_lossy().into_owned())];
|
||||
crate::commands::run_detected_tool_command_with_timeout(
|
||||
"codex",
|
||||
&["app-server", "daemon", "restart"],
|
||||
Some(timeout),
|
||||
&extra_env,
|
||||
&codex_dir,
|
||||
)
|
||||
}
|
||||
|
||||
/// 历史文件的读改写只在一个线程里做(写入和查询会同时记)。
|
||||
fn history_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn read_history(store: &DeviceStore) -> Vec<Generation> {
|
||||
let path = store.file(HISTORY_FILENAME);
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
serde_json::from_slice(&bytes).unwrap_or_else(|error| {
|
||||
log::warn!(
|
||||
"Codex 模型目录的变化记录 {} 无法解析,当作空的: {error}",
|
||||
path.display()
|
||||
);
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
|
||||
/// 现在的目录和最后一代不同就记成新的一代,返回记完之后的历史。尽力而为:写不进去只打日志,
|
||||
/// 下次再记(判断会偏向「旧」)。
|
||||
fn record(store: &DeviceStore, fingerprint: &str, now_ms: u64) -> Vec<Generation> {
|
||||
let _guard = history_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let mut history = read_history(store);
|
||||
if history
|
||||
.last()
|
||||
.is_some_and(|last| last.fingerprint == fingerprint)
|
||||
{
|
||||
return history;
|
||||
}
|
||||
history.push(Generation {
|
||||
since_ms: now_ms,
|
||||
fingerprint: fingerprint.to_string(),
|
||||
});
|
||||
let excess = history.len().saturating_sub(HISTORY_LIMIT);
|
||||
history.drain(..excess);
|
||||
if let Err(error) = crate::config::write_json_file(&store.file(HISTORY_FILENAME), &history) {
|
||||
log::warn!("记录 Codex 模型目录的变化失败: {error}");
|
||||
}
|
||||
history
|
||||
}
|
||||
|
||||
/// 新启动的 Codex 现在会读到的目录:`config.toml` 顶层指向 CC Switch 的目录时是文件内容的
|
||||
/// hash,否则是 [`NO_CATALOG`]。
|
||||
fn current_fingerprint() -> String {
|
||||
if !live_catalog_is_ours(&read_config_text()) {
|
||||
return NO_CATALOG.to_string();
|
||||
}
|
||||
std::fs::read(get_codex_model_catalog_path())
|
||||
.map(|bytes| sha256_hex(&bytes))
|
||||
.unwrap_or_else(|_| NO_CATALOG.to_string())
|
||||
}
|
||||
|
||||
/// 记下新启动的 Codex 现在会读到的目录。Codex 的客户端文件每写一次(直连、进出代理、Stack
|
||||
/// 增删、退出时写回)和启动接上之后各调一次。
|
||||
pub(crate) fn observe(store: &DeviceStore) {
|
||||
record(store, ¤t_fingerprint(), (env().now_ms)());
|
||||
}
|
||||
|
||||
/// 还在用旧目录的 Codex 客户端。都是新的,或者新启动的 Codex 本来就不读 CC Switch 的目录时
|
||||
/// 为 `None`。要读进程表,放到阻塞线程池里调。
|
||||
pub(crate) fn stale_clients(store: &DeviceStore) -> Option<StaleClients> {
|
||||
let env = env();
|
||||
let current = current_fingerprint();
|
||||
// 顺手记一次:兜住在 CC Switch 之外改了目录的情况。
|
||||
let history = record(store, ¤t, (env.now_ms)());
|
||||
if current == NO_CATALOG {
|
||||
return None;
|
||||
}
|
||||
judge(&history, ¤t, &probe(&env))
|
||||
}
|
||||
|
||||
fn judge(history: &[Generation], current: &str, servers: &AppServers) -> Option<StaleClients> {
|
||||
let daemon = servers
|
||||
.daemon
|
||||
.is_some_and(|started| is_stale(history, current, started));
|
||||
let others = servers
|
||||
.others
|
||||
.iter()
|
||||
.any(|&started| is_stale(history, current, started));
|
||||
(daemon || others).then_some(StaleClients { daemon, others })
|
||||
}
|
||||
|
||||
/// 启动于 `started_ms` 的进程读到的是不是别的目录。早于记下的第一代、判断不了的按旧的算。
|
||||
fn is_stale(history: &[Generation], current: &str, started_ms: u64) -> bool {
|
||||
history
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|generation| generation.since_ms.saturating_add(MARGIN_MS) <= started_ms)
|
||||
.is_none_or(|generation| generation.fingerprint != current)
|
||||
}
|
||||
|
||||
fn probe(env: &Env) -> AppServers {
|
||||
let Some(table) = (env.process_table)() else {
|
||||
return AppServers::default();
|
||||
};
|
||||
let plugins = format!("{}/", get_codex_config_dir().join("plugins").display());
|
||||
classify(&table, daemon_pid(), &plugins, (env.now_ms)())
|
||||
}
|
||||
|
||||
/// 守护进程自己记的 pid(`<Codex 目录>/app-server-daemon/daemon.pid`)。
|
||||
fn daemon_pid() -> Option<u32> {
|
||||
let path = get_codex_config_dir()
|
||||
.join("app-server-daemon")
|
||||
.join("daemon.pid");
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
serde_json::from_slice::<Value>(&bytes)
|
||||
.ok()?
|
||||
.get("pid")?
|
||||
.as_u64()
|
||||
.and_then(|pid| u32::try_from(pid).ok())
|
||||
}
|
||||
|
||||
/// 从进程表里挑出 app-server。守护进程要 pid 和 `daemon.pid` 对得上、命令行还是
|
||||
/// `--managed-daemon`(防止 pid 被别的进程复用),别的配置目录的守护进程不算。`plugins_dir`
|
||||
/// 下的是 Chrome 插件自带的,没有模型选择器,不算。
|
||||
fn classify(table: &str, daemon_pid: Option<u32>, plugins_dir: &str, now_ms: u64) -> AppServers {
|
||||
let mut servers = AppServers::default();
|
||||
for line in table.lines() {
|
||||
let Some((pid, elapsed_secs, command)) = split_row(line) else {
|
||||
continue;
|
||||
};
|
||||
let Some(role) = app_server_role(command) else {
|
||||
continue;
|
||||
};
|
||||
let started = now_ms.saturating_sub(elapsed_secs.saturating_mul(1000));
|
||||
match role {
|
||||
Role::Daemon if Some(pid) == daemon_pid => servers.daemon = Some(started),
|
||||
Role::Daemon => {}
|
||||
Role::Other if command.starts_with(plugins_dir) => {}
|
||||
Role::Other => servers.others.push(started),
|
||||
}
|
||||
}
|
||||
servers
|
||||
}
|
||||
|
||||
/// `pid etime command` 一行。
|
||||
fn split_row(line: &str) -> Option<(u32, u64, &str)> {
|
||||
let (pid, rest) = line.trim_start().split_once(char::is_whitespace)?;
|
||||
let (etime, command) = rest.trim_start().split_once(char::is_whitespace)?;
|
||||
Some((pid.parse().ok()?, parse_etime(etime)?, command.trim_start()))
|
||||
}
|
||||
|
||||
/// `ps` 的 etime:`[[dd-]hh:]mm:ss`,换成秒。
|
||||
fn parse_etime(value: &str) -> Option<u64> {
|
||||
let (days, clock) = match value.split_once('-') {
|
||||
Some((days, clock)) => (days.parse::<u64>().ok()?, clock),
|
||||
None => (0, value),
|
||||
};
|
||||
let parts: Vec<&str> = clock.split(':').collect();
|
||||
if !(2..=3).contains(&parts.len()) {
|
||||
return None;
|
||||
}
|
||||
let mut secs = 0u64;
|
||||
for part in parts {
|
||||
secs = secs * 60 + part.parse::<u64>().ok()?;
|
||||
}
|
||||
Some(days * 86_400 + secs)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Role {
|
||||
Daemon,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// `…/codex [-c 键=值 …] app-server …` 是 app-server;`app-server daemon …`、`app-server proxy`
|
||||
/// 是管理守护进程的子命令,不读模型目录。命令行是 `ps` 用空格拼起来的,分不出路径里的空格,
|
||||
/// 所以先找可执行文件名 `codex`,再看它后面的参数。
|
||||
fn app_server_role(command: &str) -> Option<Role> {
|
||||
let mut words = after_codex_executable(command)?.split_whitespace();
|
||||
let subcommand = loop {
|
||||
match words.next()? {
|
||||
"-c" | "--config" => {
|
||||
words.next()?;
|
||||
}
|
||||
flag if flag.starts_with('-') => {}
|
||||
word => break word,
|
||||
}
|
||||
};
|
||||
if subcommand != "app-server" {
|
||||
return None;
|
||||
}
|
||||
let rest: Vec<&str> = words.collect();
|
||||
if matches!(rest.first(), Some(&"daemon") | Some(&"proxy")) {
|
||||
return None;
|
||||
}
|
||||
Some(if rest.contains(&"--managed-daemon") {
|
||||
Role::Daemon
|
||||
} else {
|
||||
Role::Other
|
||||
})
|
||||
}
|
||||
|
||||
/// 命令行里可执行文件 `codex`(`codex` 或 `…/codex`)之后的部分。
|
||||
fn after_codex_executable(command: &str) -> Option<&str> {
|
||||
const NAME: &str = "codex";
|
||||
let mut from = 0;
|
||||
while let Some(found) = command[from..].find(NAME) {
|
||||
let start = from + found;
|
||||
let end = start + NAME.len();
|
||||
let rest = &command[end..];
|
||||
if (start == 0 || command[..start].ends_with('/'))
|
||||
&& (rest.is_empty() || rest.starts_with(' '))
|
||||
{
|
||||
return Some(rest);
|
||||
}
|
||||
from = end;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 用户确认之后重启托管守护进程:Codex 自己的 `codex app-server daemon restart`,先等守护进程
|
||||
/// 里在跑的任务收尾(最多一个宽限期),再起新的。守护进程没在跑就什么都不做。
|
||||
pub(crate) fn restart_daemon() -> Result<RestartOutcome, String> {
|
||||
let env = env();
|
||||
if probe(&env).daemon.is_none() {
|
||||
return Ok(RestartOutcome::NotRunning);
|
||||
}
|
||||
let output = (env.restart)(restart_timeout())?;
|
||||
let stdout = crate::commands::decode_command_output(&output.stdout);
|
||||
if !output.status.success() {
|
||||
let stderr = crate::commands::decode_command_output(&output.stderr);
|
||||
let detail = if stderr.trim().is_empty() {
|
||||
stdout.trim()
|
||||
} else {
|
||||
stderr.trim()
|
||||
};
|
||||
return Err(if detail.is_empty() {
|
||||
"重启 Codex 守护进程失败 (Failed to restart the Codex daemon)".to_string()
|
||||
} else {
|
||||
format!("重启 Codex 守护进程失败 (Failed to restart the Codex daemon): {detail}")
|
||||
});
|
||||
}
|
||||
Ok(match lifecycle_status(&stdout).as_deref() {
|
||||
Some("notRunning") => RestartOutcome::NotRunning,
|
||||
_ => RestartOutcome::Restarted,
|
||||
})
|
||||
}
|
||||
|
||||
/// `codex app-server daemon` 的子命令在 stdout 最后一行打一个 JSON(codex-rs `LifecycleOutput`)。
|
||||
fn lifecycle_status(stdout: &str) -> Option<String> {
|
||||
let line = stdout.lines().rev().find(|line| !line.trim().is_empty())?;
|
||||
serde_json::from_str::<Value>(line.trim())
|
||||
.ok()?
|
||||
.get("status")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// 守护进程的停机宽限期(`<Codex 目录>/app-server-daemon/settings.json` 的
|
||||
/// `shutdownGraceSeconds`)加上起新进程的余量。
|
||||
fn restart_timeout() -> Duration {
|
||||
let grace = std::fs::read(
|
||||
get_codex_config_dir()
|
||||
.join("app-server-daemon")
|
||||
.join("settings.json"),
|
||||
)
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
|
||||
.and_then(|settings| settings.get("shutdownGraceSeconds")?.as_u64())
|
||||
.unwrap_or(DEFAULT_SHUTDOWN_GRACE_SECS)
|
||||
.min(MAX_SHUTDOWN_GRACE_SECS);
|
||||
Duration::from_secs(grace) + RESTART_MARGIN
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
const DAEMON: &str =
|
||||
"/Users/me/.codex/packages/app-server-daemon/releases/0.159.2-aarch64-apple-darwin/bin/codex";
|
||||
|
||||
/// 临时 home(历史文件和 Codex 目录都落在这里),结束时换回真的依赖。
|
||||
struct Scope {
|
||||
dir: tempfile::TempDir,
|
||||
saved: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
fn new() -> Self {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let saved = std::env::var_os("CC_SWITCH_TEST_HOME");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", dir.path());
|
||||
std::fs::create_dir_all(get_codex_config_dir()).unwrap();
|
||||
Self { dir, saved }
|
||||
}
|
||||
|
||||
fn store(&self) -> DeviceStore {
|
||||
DeviceStore::at(self.dir.path().join(".cc-switch"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scope {
|
||||
fn drop(&mut self) {
|
||||
reset_test_env();
|
||||
match self.saved.take() {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generation(since_ms: u64, fingerprint: &str) -> Generation {
|
||||
Generation {
|
||||
since_ms,
|
||||
fingerprint: fingerprint.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 假的时钟和进程表:`clock` 是现在(Unix 毫秒),`table` 是 `ps` 的输出。
|
||||
fn fake_env(clock: Arc<AtomicU64>, table: Arc<Mutex<String>>, restarted: Arc<AtomicBool>) {
|
||||
let now = clock.clone();
|
||||
set_test_env(Env {
|
||||
process_table: Box::new(move || Some(table.lock().unwrap().clone())),
|
||||
now_ms: Box::new(move || now.load(Ordering::SeqCst)),
|
||||
restart: Box::new(move |_| {
|
||||
restarted.store(true, Ordering::SeqCst);
|
||||
Ok(success(r#"{"status":"restarted","pid":1}"#))
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn success(stdout: &str) -> Output {
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
Output {
|
||||
status: std::process::ExitStatus::from_raw(0),
|
||||
stdout: format!("{stdout}\n").into_bytes(),
|
||||
stderr: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn success(stdout: &str) -> Output {
|
||||
use std::os::windows::process::ExitStatusExt;
|
||||
Output {
|
||||
status: std::process::ExitStatus::from_raw(0),
|
||||
stdout: format!("{stdout}\n").into_bytes(),
|
||||
stderr: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_daemon_pid(pid: u32) {
|
||||
let dir = get_codex_config_dir().join("app-server-daemon");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("daemon.pid"), format!(r#"{{"pid":{pid}}}"#)).unwrap();
|
||||
}
|
||||
|
||||
/// 让新启动的 Codex 读 CC Switch 的目录(`catalog` 是目录内容),`None` 是撤掉指针。
|
||||
fn point_at_catalog(catalog: Option<&str>) {
|
||||
let config = get_codex_config_dir().join("config.toml");
|
||||
match catalog {
|
||||
Some(content) => {
|
||||
std::fs::write(
|
||||
&config,
|
||||
"model_provider = \"cc-switch-official\"\nmodel_catalog_json = \"cc-switch-model-catalog.json\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(get_codex_model_catalog_path(), content).unwrap();
|
||||
}
|
||||
None => std::fs::write(&config, "model = \"gpt-6-astra\"\n").unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_etime() {
|
||||
assert_eq!(parse_etime("05:03"), Some(303));
|
||||
assert_eq!(parse_etime("02:53:10"), Some(2 * 3600 + 53 * 60 + 10));
|
||||
assert_eq!(parse_etime("3-01:02:03"), Some(3 * 86_400 + 3723));
|
||||
assert_eq!(
|
||||
parse_etime("02-11:19:30"),
|
||||
Some(2 * 86_400 + 11 * 3600 + 19 * 60 + 30)
|
||||
);
|
||||
assert_eq!(parse_etime("12"), None);
|
||||
assert_eq!(parse_etime("a:b"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_app_servers() {
|
||||
let table = [
|
||||
format!("35946 02-11:19:30 {DAEMON} app-server daemon pid-update-loop"),
|
||||
format!("59013 07:19 {DAEMON} app-server --listen unix:// --managed-daemon"),
|
||||
// 另一个配置目录的守护进程:pid 对不上,不算。
|
||||
format!("70000 00:10 {DAEMON} app-server --listen unix:// --managed-daemon"),
|
||||
"62347 04:57 /Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex -c features.code_mode_host=true app-server --analytics-default-enabled -c plugins.codex-app-tools@openai-bundled.mcp_servers.codex_app.enabled=true".to_string(),
|
||||
"83919 02:11:54 /Users/me/.codex/plugins/.plugin-appserver/codex-cli/CodexCLI.app/Contents/MacOS/codex app-server --analytics-default-enabled".to_string(),
|
||||
"81234 01:00 /Users/me/Library/Application Support/Code/User/globalStorage/openai.chatgpt/bin/codex app-server".to_string(),
|
||||
"90000 00:05 /opt/homebrew/bin/codex app-server proxy".to_string(),
|
||||
"91000 00:05 /opt/homebrew/bin/codex --no-daemon".to_string(),
|
||||
"92000 00:05 /usr/bin/vim /Users/me/.codex/config.toml".to_string(),
|
||||
"93000 00:05 /Users/me/codex-tools/run app-server".to_string(),
|
||||
]
|
||||
.join("\n");
|
||||
let now = 10_000_000;
|
||||
let servers = classify(&table, Some(59013), "/Users/me/.codex/plugins/", now);
|
||||
assert_eq!(servers.daemon, Some(now - 439_000));
|
||||
assert_eq!(servers.others, vec![now - 297_000, now - 60_000]);
|
||||
// daemon.pid 指向的进程不是守护进程(pid 被复用了):不算守护进程。
|
||||
assert_eq!(
|
||||
classify(&table, Some(62347), "/Users/me/.codex/plugins/", now).daemon,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn judges_by_generation_started_before() {
|
||||
let history = [generation(1_000_000, "a"), generation(2_000_000, "b")];
|
||||
// 在 b 之后启动:读到的是现在这份。
|
||||
assert!(!is_stale(&history, "b", 2_000_000 + MARGIN_MS));
|
||||
// 离 b 开始不到余量:当它读到的是 a。
|
||||
assert!(is_stale(&history, "b", 2_000_000 + MARGIN_MS - 1));
|
||||
assert!(is_stale(&history, "b", 1_500_000));
|
||||
// 早于第一代:判断不了,按旧的算。
|
||||
assert!(is_stale(&history, "b", 500_000));
|
||||
// 来回切:a → b → a,在第一次 a 时启动的进程读到的正是现在这份。
|
||||
let history = [
|
||||
generation(1_000_000, "a"),
|
||||
generation(2_000_000, "b"),
|
||||
generation(3_000_000, "a"),
|
||||
];
|
||||
assert!(!is_stale(&history, "a", 1_500_000));
|
||||
assert!(is_stale(&history, "a", 2_500_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn records_only_changes_and_trims() {
|
||||
let scope = Scope::new();
|
||||
let store = scope.store();
|
||||
assert_eq!(record(&store, "a", 1).len(), 1);
|
||||
assert_eq!(record(&store, "a", 2), vec![generation(1, "a")]);
|
||||
for step in 0..40u64 {
|
||||
record(&store, &format!("f{step}"), 10 + step);
|
||||
}
|
||||
let history = read_history(&store);
|
||||
assert_eq!(history.len(), HISTORY_LIMIT);
|
||||
assert_eq!(history.last(), Some(&generation(49, "f39")));
|
||||
assert_eq!(history.first(), Some(&generation(18, "f8")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn fingerprint_follows_the_catalog_pointer() {
|
||||
let _scope = Scope::new();
|
||||
assert_eq!(current_fingerprint(), NO_CATALOG);
|
||||
point_at_catalog(Some("{\"models\":[]}"));
|
||||
let fingerprint = current_fingerprint();
|
||||
assert_eq!(fingerprint, sha256_hex(b"{\"models\":[]}"));
|
||||
// 指向别人的目录:新启动的 Codex 不读 CC Switch 的目录。
|
||||
std::fs::write(
|
||||
get_codex_config_dir().join("config.toml"),
|
||||
"model_catalog_json = \"/elsewhere/models.json\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(current_fingerprint(), NO_CATALOG);
|
||||
point_at_catalog(None);
|
||||
assert_eq!(current_fingerprint(), NO_CATALOG);
|
||||
}
|
||||
|
||||
/// 进 Stack → 守护进程启动 → 退出 CC Switch(撤掉指针)→ 再打开(写回同一份目录):守护进程
|
||||
/// 读到的正是现在这份,不提示。退出期间重启过的守护进程读到的是没有目录的配置,要提示。
|
||||
#[test]
|
||||
#[serial]
|
||||
fn quitting_and_reopening_cc_switch_is_not_stale() {
|
||||
let scope = Scope::new();
|
||||
let store = scope.store();
|
||||
let clock = Arc::new(AtomicU64::new(1_000_000));
|
||||
let table = Arc::new(Mutex::new(String::new()));
|
||||
fake_env(
|
||||
clock.clone(),
|
||||
table.clone(),
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
);
|
||||
let daemon_row = |elapsed: &str| {
|
||||
format!("59013 {elapsed} {DAEMON} app-server --listen unix:// --managed-daemon")
|
||||
};
|
||||
write_daemon_pid(59013);
|
||||
|
||||
point_at_catalog(Some("stack"));
|
||||
observe(&store);
|
||||
// 进 Stack 之后 10 秒守护进程启动。
|
||||
clock.store(1_010_000, Ordering::SeqCst);
|
||||
*table.lock().unwrap() = daemon_row("00:00");
|
||||
clock.store(1_060_000, Ordering::SeqCst);
|
||||
*table.lock().unwrap() = daemon_row("00:50");
|
||||
assert_eq!(stale_clients(&store), None);
|
||||
|
||||
// 退出 CC Switch,一分钟后再打开。
|
||||
point_at_catalog(None);
|
||||
observe(&store);
|
||||
clock.store(1_120_000, Ordering::SeqCst);
|
||||
point_at_catalog(Some("stack"));
|
||||
observe(&store);
|
||||
*table.lock().unwrap() = daemon_row("01:50");
|
||||
assert_eq!(stale_clients(&store), None);
|
||||
|
||||
// 目录变了(Stack 增删):守护进程还拿着旧的。
|
||||
clock.store(1_200_000, Ordering::SeqCst);
|
||||
point_at_catalog(Some("stack + kimi"));
|
||||
observe(&store);
|
||||
*table.lock().unwrap() = daemon_row("03:10");
|
||||
assert_eq!(
|
||||
stale_clients(&store),
|
||||
Some(StaleClients {
|
||||
daemon: true,
|
||||
others: false
|
||||
})
|
||||
);
|
||||
|
||||
// 退出期间守护进程重启过(比如 Codex 自动升级)。
|
||||
point_at_catalog(None);
|
||||
observe(&store);
|
||||
clock.store(1_300_000, Ordering::SeqCst);
|
||||
*table.lock().unwrap() = daemon_row("00:30");
|
||||
clock.store(1_400_000, Ordering::SeqCst);
|
||||
point_at_catalog(Some("stack + kimi"));
|
||||
observe(&store);
|
||||
*table.lock().unwrap() = daemon_row("02:10");
|
||||
assert_eq!(
|
||||
stale_clients(&store),
|
||||
Some(StaleClients {
|
||||
daemon: true,
|
||||
others: false
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn desktop_app_servers_are_reported_separately() {
|
||||
let scope = Scope::new();
|
||||
let store = scope.store();
|
||||
let clock = Arc::new(AtomicU64::new(1_000_000));
|
||||
let table = Arc::new(Mutex::new(String::new()));
|
||||
fake_env(
|
||||
clock.clone(),
|
||||
table.clone(),
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
);
|
||||
point_at_catalog(Some("old"));
|
||||
observe(&store);
|
||||
clock.store(1_100_000, Ordering::SeqCst);
|
||||
point_at_catalog(Some("new"));
|
||||
observe(&store);
|
||||
clock.store(1_200_000, Ordering::SeqCst);
|
||||
// 桌面版在旧目录时启动;守护进程没在跑。
|
||||
*table.lock().unwrap() =
|
||||
"62347 02:30 /Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex -c features.code_mode_host=true app-server".to_string();
|
||||
assert_eq!(
|
||||
stale_clients(&store),
|
||||
Some(StaleClients {
|
||||
daemon: false,
|
||||
others: true
|
||||
})
|
||||
);
|
||||
// 撤掉指针之后不提示(新启动的 Codex 也不读 CC Switch 的目录)。
|
||||
point_at_catalog(None);
|
||||
assert_eq!(stale_clients(&store), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn restart_skips_when_the_daemon_is_not_running() {
|
||||
let _scope = Scope::new();
|
||||
let restarted = Arc::new(AtomicBool::new(false));
|
||||
let table = Arc::new(Mutex::new(String::new()));
|
||||
fake_env(
|
||||
Arc::new(AtomicU64::new(1_000_000)),
|
||||
table.clone(),
|
||||
restarted.clone(),
|
||||
);
|
||||
assert_eq!(restart_daemon(), Ok(RestartOutcome::NotRunning));
|
||||
assert!(!restarted.load(Ordering::SeqCst));
|
||||
|
||||
write_daemon_pid(59013);
|
||||
*table.lock().unwrap() =
|
||||
format!("59013 07:19 {DAEMON} app-server --listen unix:// --managed-daemon");
|
||||
assert_eq!(restart_daemon(), Ok(RestartOutcome::Restarted));
|
||||
assert!(restarted.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_lifecycle_status() {
|
||||
assert_eq!(
|
||||
lifecycle_status("warning: x\n{\"status\":\"restarted\",\"pid\":1}\n\n").as_deref(),
|
||||
Some("restarted")
|
||||
);
|
||||
assert_eq!(lifecycle_status("not json"), None);
|
||||
}
|
||||
}
|
||||
@@ -1075,7 +1075,10 @@ pub(crate) fn run_with_edits(
|
||||
});
|
||||
}
|
||||
|
||||
write.run(op, &changes, pending)
|
||||
let report = write.run(op, &changes, pending);
|
||||
// 按磁盘上的实际内容记新启动的 Codex 会读到的目录:失败时可能已经发布了一部分。
|
||||
super::codex_client_catalog::observe(store);
|
||||
report
|
||||
}
|
||||
|
||||
/// 直连写入:`prepare` → `plan` → `run`。
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
pub(crate) mod claude_direct;
|
||||
mod claude_editor;
|
||||
pub(crate) mod codex_client_catalog;
|
||||
pub(crate) mod codex_direct;
|
||||
mod codex_editor;
|
||||
mod codex_login;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, RotateCw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { useRestartCodexAppServerDaemon } from "@/lib/query/proxy";
|
||||
import type { CodexStaleClients } from "@/types/proxy";
|
||||
|
||||
interface CodexStaleClientsNoticeProps {
|
||||
staleClients: CodexStaleClients;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack 模式下 Codex 客户端还在用旧的模型列表(它们只在启动时读模型目录)。命令行连的守护
|
||||
* 进程确认后一键重启;桌面版、编辑器插件只提示用户彻底退出再开。重启会中断守护进程里正在
|
||||
* 运行的任务,执行期间确认框保持打开。
|
||||
*/
|
||||
export function CodexStaleClientsNotice({
|
||||
staleClients,
|
||||
}: CodexStaleClientsNoticeProps) {
|
||||
const { t } = useTranslation();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const restart = useRestartCodexAppServerDaemon();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-200"
|
||||
>
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{t("proxy.stackMode.codexStale.title")}
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1 text-xs leading-relaxed">
|
||||
{staleClients.daemon && (
|
||||
<li>{t("proxy.stackMode.codexStale.daemon")}</li>
|
||||
)}
|
||||
{staleClients.others && (
|
||||
<li>{t("proxy.stackMode.codexStale.others")}</li>
|
||||
)}
|
||||
</ul>
|
||||
{staleClients.daemon && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-3 gap-1.5"
|
||||
disabled={restart.isPending}
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
<RotateCw
|
||||
className={`h-3.5 w-3.5 ${restart.isPending ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("proxy.stackMode.codexStale.restart")}
|
||||
</Button>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
isOpen={confirming}
|
||||
title={t("proxy.stackMode.codexStale.confirmTitle")}
|
||||
message={t("proxy.stackMode.codexStale.confirmMessage")}
|
||||
confirmText={t("proxy.stackMode.codexStale.confirm")}
|
||||
pending={restart.isPending}
|
||||
onConfirm={() =>
|
||||
restart.mutate(undefined, {
|
||||
onSettled: () => setConfirming(false),
|
||||
})
|
||||
}
|
||||
onCancel={() => setConfirming(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { useStreamCheck } from "@/hooks/useStreamCheck";
|
||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
||||
import { CodexStaleClientsNotice } from "@/components/providers/CodexStaleClientsNotice";
|
||||
import {
|
||||
useAutoFailoverEnabled,
|
||||
useFailoverQueue,
|
||||
@@ -178,6 +179,8 @@ export function ProviderList({
|
||||
isStackAppId(appId) && isProxyTakeover === true && stack?.active === true;
|
||||
const stackMembers = stack?.members;
|
||||
const stackNotice = isStackMode ? stack?.notice : undefined;
|
||||
const codexStaleClients =
|
||||
isStackMode && appId === "codex" ? stack?.staleClients : undefined;
|
||||
const setStackMember = useSetProxyStackMember();
|
||||
const stackMemberOf = useCallback(
|
||||
(providerId: string): ProxyStackMember | undefined =>
|
||||
@@ -584,6 +587,9 @@ export function ProviderList({
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
{piStateErrorNotice}
|
||||
{codexStaleClients && (
|
||||
<CodexStaleClientsNotice staleClients={codexStaleClients} />
|
||||
)}
|
||||
{claudeDesktopStatusMessages.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-200">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
|
||||
@@ -2918,7 +2918,19 @@
|
||||
"inactive": "Enter Stack mode: add several providers and all their models appear in the {{appLabel}} model list"
|
||||
},
|
||||
"ariaLabel": "{{appLabel}} Stack mode",
|
||||
"failoverUnavailable": "{{appLabel}} is in Stack mode, which has no failover. The queue and settings are kept and apply again in routing mode"
|
||||
"failoverUnavailable": "{{appLabel}} is in Stack mode, which has no failover. The queue and settings are kept and apply again in routing mode",
|
||||
"codexStale": {
|
||||
"title": "Codex is still using an old model list, so Stack models won't show",
|
||||
"daemon": "CLI: reopening codex isn't enough; its background daemon needs a restart.",
|
||||
"others": "Desktop app: quit it completely and reopen it (⌘Q on macOS; closing the window isn't enough). Editor extensions: reopen the editor window.",
|
||||
"restart": "Restart Codex daemon",
|
||||
"confirmTitle": "Restart the Codex daemon?",
|
||||
"confirmMessage": "Every codex session in your terminals shares this daemon, so running tasks will be interrupted. Reopen codex afterwards to see the Stack models.",
|
||||
"confirm": "Restart",
|
||||
"restarted": "Codex daemon restarted. Reopen codex to see the Stack models",
|
||||
"notRunning": "The Codex daemon isn't running. codex will load the new model list the next time it starts",
|
||||
"failed": "Failed to restart the Codex daemon: {{error}}"
|
||||
}
|
||||
},
|
||||
"failover": {
|
||||
"proxyRequired": "Routing service must be started to configure failover",
|
||||
|
||||
@@ -2918,7 +2918,19 @@
|
||||
"inactive": "Stack モードに入る:複数のプロバイダーを追加し、それらのモデルをすべて {{appLabel}} のモデル一覧に表示します"
|
||||
},
|
||||
"ariaLabel": "{{appLabel}} の Stack モード",
|
||||
"failoverUnavailable": "{{appLabel}} は Stack モードのため、フェイルオーバーは行いません。キューと設定は保持され、ルーティングモードに戻すと再び有効になります"
|
||||
"failoverUnavailable": "{{appLabel}} は Stack モードのため、フェイルオーバーは行いません。キューと設定は保持され、ルーティングモードに戻すと再び有効になります",
|
||||
"codexStale": {
|
||||
"title": "Codex が古いモデル一覧を使っているため、Stack モデルが表示されません",
|
||||
"daemon": "CLI:codex を開き直すだけでは反映されません。バックグラウンドのデーモンを再起動する必要があります。",
|
||||
"others": "デスクトップ版:完全に終了してから開き直してください(macOS では ⌘Q。ウィンドウを閉じるだけでは不十分です)。エディター拡張:エディターのウィンドウを開き直してください。",
|
||||
"restart": "Codex デーモンを再起動",
|
||||
"confirmTitle": "Codex デーモンを再起動しますか?",
|
||||
"confirmMessage": "ターミナルで動いているすべての codex がこのデーモンを共有しているため、実行中のタスクは中断されます。再起動後に codex を開き直すと Stack モデルが表示されます。",
|
||||
"confirm": "再起動",
|
||||
"restarted": "Codex デーモンを再起動しました。codex を開き直すと Stack モデルが表示されます",
|
||||
"notRunning": "Codex デーモンは動いていません。次に codex を起動したときに新しいモデル一覧が読み込まれます",
|
||||
"failed": "Codex デーモンの再起動に失敗しました:{{error}}"
|
||||
}
|
||||
},
|
||||
"failover": {
|
||||
"proxyRequired": "フェイルオーバーを設定するには、ルーティングサービスを先に起動する必要があります",
|
||||
|
||||
@@ -2919,7 +2919,19 @@
|
||||
"inactive": "進入 Stack 模式:新增多家供應商,它們的模型都出現在 {{appLabel}} 的模型清單裡"
|
||||
},
|
||||
"ariaLabel": "{{appLabel}} Stack 模式",
|
||||
"failoverUnavailable": "{{appLabel}} 在 Stack 模式,不做故障轉移。佇列和設定保留,回到路由模式後恢復"
|
||||
"failoverUnavailable": "{{appLabel}} 在 Stack 模式,不做故障轉移。佇列和設定保留,回到路由模式後恢復",
|
||||
"codexStale": {
|
||||
"title": "Codex 還在用舊的模型清單,Stack 模型不會出現",
|
||||
"daemon": "命令列:重開 codex 不夠,要重新啟動背景的守護行程。",
|
||||
"others": "桌面版:要完全結束再重開(macOS 上按 ⌘Q,只關視窗不行);編輯器擴充功能要重開編輯器視窗。",
|
||||
"restart": "重新啟動 Codex 守護行程",
|
||||
"confirmTitle": "重新啟動 Codex 守護行程?",
|
||||
"confirmMessage": "所有終端機裡的 codex 共用這個守護行程,正在執行的任務會被中斷。重新啟動後重開 codex,就能看到 Stack 模型。",
|
||||
"confirm": "重新啟動",
|
||||
"restarted": "Codex 守護行程已重新啟動,重開 codex 就能看到 Stack 模型",
|
||||
"notRunning": "Codex 守護行程沒有在執行,下次開啟 codex 時會讀到新的模型清單",
|
||||
"failed": "重新啟動 Codex 守護行程失敗:{{error}}"
|
||||
}
|
||||
},
|
||||
"failover": {
|
||||
"proxyRequired": "需要先啟動路由服務才能設定故障轉移",
|
||||
|
||||
@@ -2918,7 +2918,19 @@
|
||||
"inactive": "进入 Stack 模式:添加多家供应商,它们的模型都出现在 {{appLabel}} 的模型列表里"
|
||||
},
|
||||
"ariaLabel": "{{appLabel}} Stack 模式",
|
||||
"failoverUnavailable": "{{appLabel}} 在 Stack 模式,不做故障转移。队列和设置保留,回到路由模式后恢复"
|
||||
"failoverUnavailable": "{{appLabel}} 在 Stack 模式,不做故障转移。队列和设置保留,回到路由模式后恢复",
|
||||
"codexStale": {
|
||||
"title": "Codex 还在用旧的模型列表,Stack 模型不会出现",
|
||||
"daemon": "命令行:重开 codex 不够,要重启后台的守护进程。",
|
||||
"others": "桌面版:要彻底退出再重开(macOS 上按 ⌘Q,只关窗口不行);编辑器插件要重开编辑器窗口。",
|
||||
"restart": "重启 Codex 守护进程",
|
||||
"confirmTitle": "重启 Codex 守护进程?",
|
||||
"confirmMessage": "所有终端里的 codex 共用这个守护进程,正在运行的任务会被中断。重启之后重新打开 codex,就能看到 Stack 模型。",
|
||||
"confirm": "重启",
|
||||
"restarted": "Codex 守护进程已重启,重新打开 codex 就能看到 Stack 模型",
|
||||
"notRunning": "Codex 守护进程没在运行,下次打开 codex 时会读到新的模型列表",
|
||||
"failed": "重启 Codex 守护进程失败:{{error}}"
|
||||
}
|
||||
},
|
||||
"failover": {
|
||||
"proxyRequired": "需要先启动路由服务才能配置故障转移",
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
AppProxyConfig,
|
||||
ProxyStack,
|
||||
ProxyStackNotice,
|
||||
CodexDaemonRestartOutcome,
|
||||
} from "@/types/proxy";
|
||||
|
||||
export const proxyApi = {
|
||||
@@ -76,6 +77,12 @@ export const proxyApi = {
|
||||
return invoke("set_proxy_stack_member", { appType, providerId, enabled });
|
||||
},
|
||||
|
||||
// 重启 Codex 的托管守护进程(codex 命令行连的那个),让它重读模型目录。会中断正在运行的
|
||||
// 任务,只在用户确认之后调
|
||||
async restartCodexAppServerDaemon(): Promise<CodexDaemonRestartOutcome> {
|
||||
return invoke("restart_codex_app_server_daemon");
|
||||
},
|
||||
|
||||
// ========== v3+ 全局/应用级配置 API ==========
|
||||
|
||||
// 获取全局代理配置
|
||||
|
||||
@@ -137,6 +137,40 @@ export function useSetProxyStackMember() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重启 Codex 的托管守护进程,让它重读模型目录。结束后重新查 Stack 名单:重启成功时
|
||||
* 「还在用旧模型列表」的提示随之消失。
|
||||
*/
|
||||
export function useRestartCodexAppServerDaemon() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => proxyApi.restartCodexAppServerDaemon(),
|
||||
onSuccess: (outcome) => {
|
||||
if (outcome === "notRunning") {
|
||||
toast.info(t("proxy.stackMode.codexStale.notRunning"), {
|
||||
closeButton: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success(t("proxy.stackMode.codexStale.restarted"), {
|
||||
closeButton: true,
|
||||
});
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(
|
||||
t("proxy.stackMode.codexStale.failed", {
|
||||
error: extractErrorMessage(error),
|
||||
}),
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["providers", "codex"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 代理服务器控制 Hooks ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,14 +72,27 @@ export type ProxyStackNotice =
|
||||
| "officialModelsBundled"
|
||||
| "officialModelsUnavailable";
|
||||
|
||||
/**
|
||||
* 还在用旧模型列表的 Codex 客户端(它们只在启动时读模型目录):`daemon` 是 `codex` 命令行连的
|
||||
* 托管守护进程,可以一键重启;`others` 是桌面版、编辑器插件,要用户自己彻底退出再开。
|
||||
*/
|
||||
export interface CodexStaleClients {
|
||||
daemon: boolean;
|
||||
others: boolean;
|
||||
}
|
||||
|
||||
/** Stack 模式的状态、名单和提示。 */
|
||||
export interface ProxyStack {
|
||||
/** 在 Stack 模式(代理模式且 Stack 模式开着)。 */
|
||||
active: boolean;
|
||||
members: ProxyStackMember[];
|
||||
notice?: ProxyStackNotice;
|
||||
staleClients?: CodexStaleClients;
|
||||
}
|
||||
|
||||
/** 重启 Codex 守护进程的结果:`notRunning` 表示它没在运行,什么都没做。 */
|
||||
export type CodexDaemonRestartOutcome = "restarted" | "notRunning";
|
||||
|
||||
/** 增删 Stack 模型失败。`partial` 为真:已部分写入,下次操作或重启 CC Switch 时补完。 */
|
||||
export interface ProxyStackWriteError {
|
||||
partial: boolean;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { toast } from "sonner";
|
||||
import { CodexStaleClientsNotice } from "@/components/providers/CodexStaleClientsNotice";
|
||||
import type { CodexStaleClients } from "@/types/proxy";
|
||||
import { server } from "../msw/server";
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const TAURI_ENDPOINT = "http://tauri.local";
|
||||
const RESTART = `${TAURI_ENDPOINT}/restart_codex_app_server_daemon`;
|
||||
|
||||
function renderNotice(staleClients: CodexStaleClients) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CodexStaleClientsNotice staleClients={staleClients} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { invalidate };
|
||||
}
|
||||
|
||||
function clickRestartAndConfirm() {
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "proxy.stackMode.codexStale.restart" }),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "proxy.stackMode.codexStale.confirm" }),
|
||||
);
|
||||
}
|
||||
|
||||
describe("CodexStaleClientsNotice", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("restarts the daemon only after the user confirms", async () => {
|
||||
let restarts = 0;
|
||||
server.use(
|
||||
http.post(RESTART, () => {
|
||||
restarts += 1;
|
||||
return HttpResponse.json("restarted");
|
||||
}),
|
||||
);
|
||||
const { invalidate } = renderNotice({ daemon: true, others: false });
|
||||
|
||||
expect(
|
||||
screen.getByText("proxy.stackMode.codexStale.daemon"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("proxy.stackMode.codexStale.others"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "proxy.stackMode.codexStale.restart" }),
|
||||
);
|
||||
// 只是打开确认框:会中断正在运行的任务,没确认之前不重启。
|
||||
expect(
|
||||
screen.getByText("proxy.stackMode.codexStale.confirmMessage"),
|
||||
).toBeInTheDocument();
|
||||
expect(restarts).toBe(0);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "proxy.stackMode.codexStale.confirm" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
"proxy.stackMode.codexStale.restarted",
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
expect(restarts).toBe(1);
|
||||
// 重新查 Stack 名单,提示随之消失。
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["providers", "codex"] });
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText("proxy.stackMode.codexStale.confirmMessage"),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("says so when the daemon is not running", async () => {
|
||||
server.use(http.post(RESTART, () => HttpResponse.json("notRunning")));
|
||||
renderNotice({ daemon: true, others: true });
|
||||
clickRestartAndConfirm();
|
||||
await waitFor(() =>
|
||||
expect(toast.info).toHaveBeenCalledWith(
|
||||
"proxy.stackMode.codexStale.notRunning",
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the failure", async () => {
|
||||
server.use(
|
||||
http.post(RESTART, () =>
|
||||
HttpResponse.json("重启 Codex 守护进程失败: boom", { status: 500 }),
|
||||
),
|
||||
);
|
||||
renderNotice({ daemon: true, others: false });
|
||||
clickRestartAndConfirm();
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"proxy.stackMode.codexStale.failed",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("only explains how to reopen the desktop app", () => {
|
||||
renderNotice({ daemon: false, others: true });
|
||||
expect(
|
||||
screen.getByText("proxy.stackMode.codexStale.others"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: "proxy.stackMode.codexStale.restart",
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -527,6 +527,70 @@ describe("ProviderList Component", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when Codex clients still use an old model list in Stack mode", async () => {
|
||||
const route = createProvider({ id: "route", name: "Route" });
|
||||
useDragSortMock.mockReturnValue({
|
||||
sortedProviders: [route],
|
||||
sensors: [],
|
||||
handleDragEnd: vi.fn(),
|
||||
});
|
||||
let active = true;
|
||||
server.use(
|
||||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||||
HttpResponse.json({
|
||||
active,
|
||||
members: [
|
||||
{ providerId: "route", modelIds: [], route: true },
|
||||
],
|
||||
staleClients: { daemon: true, others: false },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const view = renderWithQueryClient(
|
||||
<ProviderList
|
||||
providers={{ route }}
|
||||
currentProviderId="route"
|
||||
appId="codex"
|
||||
isProxyTakeover
|
||||
onSwitch={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onDuplicate={vi.fn()}
|
||||
onOpenWebsite={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("proxy.stackMode.codexStale.title"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "proxy.stackMode.codexStale.restart" }),
|
||||
).toBeInTheDocument();
|
||||
view.unmount();
|
||||
|
||||
// 不在 Stack 模式:不提示。
|
||||
active = false;
|
||||
renderWithQueryClient(
|
||||
<ProviderList
|
||||
providers={{ route }}
|
||||
currentProviderId="route"
|
||||
appId="codex"
|
||||
isProxyTakeover
|
||||
onSwitch={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onDuplicate={vi.fn()}
|
||||
onOpenWebsite={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(lastProps("route")?.isStackMode).toBe(false),
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("proxy.stackMode.codexStale.title"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Stack mode outside proxy mode and for apps without it", async () => {
|
||||
const provider = createProvider({ id: "a", name: "A" });
|
||||
useDragSortMock.mockReturnValue({
|
||||
|
||||
@@ -366,6 +366,10 @@ export const handlers = [
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/set_proxy_stack_member`, () => success(null)),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/restart_codex_app_server_daemon`, () =>
|
||||
success("restarted"),
|
||||
),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/get_proxy_takeover_status`, () =>
|
||||
success({
|
||||
claude: false,
|
||||
|
||||
Reference in new issue
Block a user