Compare commits

...
4 Commits
Author SHA1 Message Date
ZyyandCodex 589e1bc999 chore(release): v3.15.2
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 04:51:51 +08:00
ZyyandCodex 2956f07723 feat(system): add update channels and reconcile installation state
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 04:41:53 +08:00
ZyyandCodex bb76399e59 chore(release): v3.15.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 03:19:48 +08:00
ZyyandCodex 66a3474baf fix(openai): update desktop Core extraction and identity layout
Read bundled Core from the current CodexCLI.app layout and reject legacy
and launcher paths. Keep client identity aligned to the right edge on the
same row and to the left edge after wrapping on narrow cards.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 03:16:06 +08:00
32 changed files with 1372 additions and 285 deletions
+3 -3
View File
@@ -46,9 +46,9 @@ description: 准备、执行和排查 Codex Proxy RS 发版。用户说“发个
- alpha 是下一正式版本的早期开发阶段,beta 是功能测试阶段,rc 是发布候选,exp 是临时功能实验。
预发行采用 `vX.Y.Z-<阶段>.N`,`N` 从 1 开始;正式版不带预发行后缀,不新增未约定的通道名称。
- 正式版只接收同一大版本内更新的正式版。alpha → beta → rc → 正式版只在相同 `X.Y.Z` 内晋级,
可以跳过阶段,不能倒退;转正后不自动进入下一轮预发行。exp 仅接收同基线、同一轮实验的后续编号,
不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
- 更新候选由本次检查或安装选择的通道决定:Stable 仅正式版,RC 包含正式版,Beta 包含 RC 与正式版,Alpha 包含所有常规阶段。
仅接收同一大版本内严格更高的版本,可跟随后续小版本与补丁版本,默认按运行版本推导通道;临时选择不保存、不允许降级。
exp 仅接收同基线、同一轮实验的后续编号,不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
- 核对目标工作流的构建类型:正式版及 alpha、beta、rc 为 `release`,exp 为 `experimental`。
所有预发行均标记为 Pre-release,不设置 GitHub Latest、不覆盖镜像 `latest`。
发布预发行前记录这两个稳定入口,发布后回读确认未改变。
@@ -1,6 +1,40 @@
//! 版本、自更新、回滚与进程重启的 UTC 语义模型。
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// 本次检查或安装选择的更新稳定性范围,具体版本准入由 Host 判定。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SystemUpdateChannel {
Stable,
Alpha,
Beta,
Rc,
#[serde(rename = "exp")]
Experimental,
}
impl SystemUpdateChannel {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Stable => "stable",
Self::Alpha => "alpha",
Self::Beta => "beta",
Self::Rc => "rc",
Self::Experimental => "exp",
}
}
}
/// Host 返回的本次检查通道及当前构建允许选择的范围。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SystemUpdatePolicy {
pub channel: SystemUpdateChannel,
pub available_channels: Vec<SystemUpdateChannel>,
}
/// 当前运行版本及更新检查摘要。
///
@@ -23,6 +57,7 @@ pub struct SystemVersion {
/// 部署模式与构建类型的展示标签归 API;其余字段均为 Host 已确认的原始事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemUpdateDetail {
pub policy: SystemUpdatePolicy,
pub current_version: String,
pub latest_version: String,
pub has_update: bool,
@@ -7,8 +7,8 @@ use futures::Stream;
use crate::model::Revision;
use crate::model::system::{
SystemOperationAccepted, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateStatus,
SystemVersion,
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent,
SystemUpdateStatus, SystemVersion,
};
/// Host 已校验并解包的同一更新候选;字节只含发行清单,不含运行配置或凭据。
@@ -89,6 +89,7 @@ pub trait SystemOperations: Send + Sync {
async fn update_detail(
&self,
refresh: bool,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError>;
fn update_events(&self) -> SystemUpdateEventStream;
@@ -96,6 +97,7 @@ pub trait SystemOperations: Send + Sync {
async fn perform_update(
&self,
target_version: Option<String>,
channel: Option<SystemUpdateChannel>,
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError>;
@@ -7,7 +7,10 @@ use async_trait::async_trait;
use crate::{
model::{
AdminError, AdminErrorKind,
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
system::{
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateStatus,
SystemVersion,
},
},
ports::system::{
SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateEventStream,
@@ -19,11 +22,16 @@ use crate::{
#[async_trait]
pub trait SystemService: Send + Sync {
async fn version(&self) -> Result<SystemVersion, AdminError>;
async fn update_detail(&self, refresh: bool) -> Result<SystemUpdateDetail, AdminError>;
async fn update_detail(
&self,
refresh: bool,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, AdminError>;
fn update_events(&self) -> SystemUpdateEventStream;
async fn perform_update(
&self,
target_version: Option<String>,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemOperationAccepted, AdminError>;
async fn update_status(&self) -> Result<SystemUpdateStatus, AdminError>;
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError>;
@@ -55,9 +63,13 @@ impl SystemService for DefaultSystemService {
self.operations.version().await.map_err(map_system_error)
}
async fn update_detail(&self, refresh: bool) -> Result<SystemUpdateDetail, AdminError> {
async fn update_detail(
&self,
refresh: bool,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, AdminError> {
self.operations
.update_detail(refresh)
.update_detail(refresh, channel)
.await
.map_err(map_system_error)
}
@@ -69,12 +81,13 @@ impl SystemService for DefaultSystemService {
async fn perform_update(
&self,
target_version: Option<String>,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemOperationAccepted, AdminError> {
let target_version = target_version
.map(|version| version.trim().to_owned())
.filter(|version| !version.is_empty());
self.operations
.perform_update(target_version, Arc::clone(&self.preflight))
.perform_update(target_version, channel, Arc::clone(&self.preflight))
.await
.map_err(map_system_error)
}
@@ -1035,7 +1035,11 @@ impl SystemOperations for UnavailableSystem {
Err(unavailable_system())
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
async fn update_detail(
&self,
_: bool,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError> {
Err(unavailable_system())
}
@@ -1046,6 +1050,7 @@ impl SystemOperations for UnavailableSystem {
async fn perform_update(
&self,
_: Option<String>,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
@@ -225,7 +225,11 @@ impl SystemOperations for PreflightingSystem {
unreachable!()
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
async fn update_detail(
&self,
_: bool,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError> {
unreachable!()
}
@@ -236,6 +240,7 @@ impl SystemOperations for PreflightingSystem {
async fn perform_update(
&self,
_: Option<String>,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
let revision = preflight.validate(self.candidate.clone()).await?;
@@ -291,7 +296,7 @@ async fn system_update_accepts_compatible_enabled_plugins_at_the_same_revision()
services
.system()
.perform_update(Some("1.2.0".into()))
.perform_update(Some("1.2.0".into()), None)
.await
.expect("compatible update");
}
@@ -309,7 +314,7 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes()
assert!(
services
.system()
.perform_update(Some("1.2.0".into()))
.perform_update(Some("1.2.0".into()), None)
.await
.is_err()
);
@@ -325,7 +330,7 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes()
assert!(
services
.system()
.perform_update(Some("1.2.0".into()))
.perform_update(Some("1.2.0".into()), None)
.await
.is_err()
);
@@ -13,6 +13,7 @@ use gateway_admin::{
#[derive(Default)]
struct RecordingSystemOperations {
target: Mutex<Option<Option<String>>>,
channel: Mutex<Option<gateway_admin::model::system::SystemUpdateChannel>>,
}
#[async_trait]
@@ -24,6 +25,7 @@ impl SystemOperations for RecordingSystemOperations {
build_time: "unknown".to_owned(),
deployment_mode: "source".to_owned(),
update_channel: "stable".to_owned(),
latest_version: "1.0.0".to_owned(),
has_update: false,
update_cached: false,
@@ -31,8 +33,16 @@ impl SystemOperations for RecordingSystemOperations {
})
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
async fn update_detail(
&self,
_: bool,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError> {
Ok(SystemUpdateDetail {
policy: gateway_admin::model::system::SystemUpdatePolicy {
channel: gateway_admin::model::system::SystemUpdateChannel::Stable,
available_channels: vec![gateway_admin::model::system::SystemUpdateChannel::Stable],
},
current_version: "1.0.0".to_owned(),
latest_version: "1.0.0".to_owned(),
has_update: false,
@@ -54,9 +64,11 @@ impl SystemOperations for RecordingSystemOperations {
async fn perform_update(
&self,
target_version: Option<String>,
channel: Option<gateway_admin::model::system::SystemUpdateChannel>,
_: std::sync::Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
*self.target.lock().expect("target") = Some(target_version.clone());
*self.channel.lock().expect("channel") = channel;
Ok(SystemOperationAccepted::Update {
operation_id: "operation-update".to_owned(),
deployment_mode: "source".to_owned(),
@@ -111,9 +123,29 @@ async fn system_update_should_normalize_blank_target_to_latest() {
.await;
services
.system()
.perform_update(Some(" ".to_owned()))
.perform_update(Some(" ".to_owned()), None)
.await
.expect("perform update");
assert_eq!(*operations.target.lock().expect("target"), Some(None));
}
#[tokio::test]
async fn system_update_should_forward_the_confirmed_channel() {
let operations = std::sync::Arc::new(RecordingSystemOperations::default());
let services = super::AdminHarness::new()
.system(operations.clone())
.build()
.await;
let channel = gateway_admin::model::system::SystemUpdateChannel::Beta;
services
.system()
.perform_update(Some(" 1.2.0-beta.1 ".into()), Some(channel))
.await
.expect("accepted");
assert_eq!(
*operations.target.lock().expect("target"),
Some(Some("1.2.0-beta.1".into()))
);
assert_eq!(*operations.channel.lock().expect("channel"), Some(channel));
}
+16 -4
View File
@@ -20,8 +20,8 @@ use axum::{
use futures::{Stream, StreamExt};
use gateway_admin::model::system::{
SystemOperationAccepted, SystemOperationKind, SystemOperationState, SystemOperationStatus,
SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel, SystemUpdateStatus,
SystemVersion,
SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel,
SystemUpdatePolicy, SystemUpdateStatus, SystemVersion,
};
use serde::{Deserialize, Serialize};
@@ -35,9 +35,15 @@ use super::{
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateDetailQuery {
refresh: Option<bool>,
channel: Option<SystemUpdateChannel>,
}
impl UpdateDetailQuery {
#[must_use]
pub const fn channel(&self) -> Option<SystemUpdateChannel> {
self.channel
}
/// 是否强制从发布源刷新。
#[must_use]
pub fn refresh(&self) -> bool {
@@ -50,6 +56,7 @@ impl UpdateDetailQuery {
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateRequest {
target_version: String,
channel: Option<SystemUpdateChannel>,
}
impl UpdateRequest {
@@ -95,6 +102,7 @@ impl From<SystemVersion> for SystemVersionView {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct SystemUpdateDetailView {
policy: SystemUpdatePolicy,
current_version: String,
latest_version: String,
has_update: bool,
@@ -115,6 +123,7 @@ impl From<SystemUpdateDetail> for SystemUpdateDetailView {
Self {
deployment_mode_label: deployment_mode_label(&detail.deployment_mode).to_owned(),
build_type_label: build_type_label(&detail.build_type),
policy: detail.policy,
current_version: detail.current_version,
latest_version: detail.latest_version,
has_update: detail.has_update,
@@ -263,7 +272,7 @@ where
let detail = state
.admin_services()
.system()
.update_detail(query.refresh())
.update_detail(query.refresh(), query.channel())
.await
.map_err(map_system_error)?;
Ok(AdminResponse::new(
@@ -299,10 +308,13 @@ async fn perform_update<S>(
where
S: SessionState + Send + Sync,
{
let (target, channel) = payload.map_or((None, None), |AdminJson(value)| {
(Some(value.target_version), value.channel)
});
let result = state
.admin_services()
.system()
.perform_update(payload.map(|AdminJson(value)| value.into_target_version()))
.perform_update(target, channel)
.await
.map_err(map_system_error)?;
let SystemOperationAccepted::Update {
@@ -1456,7 +1456,11 @@ impl SystemOperations for UnusedSystem {
Err(unavailable_system())
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
async fn update_detail(
&self,
_: bool,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError> {
Err(unavailable_system())
}
@@ -1467,6 +1471,7 @@ impl SystemOperations for UnusedSystem {
async fn perform_update(
&self,
_: Option<String>,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
@@ -125,8 +125,29 @@ impl SystemOperations for ProgressSystem {
Err(unavailable_system())
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
Err(unavailable_system())
async fn update_detail(
&self,
_: bool,
channel: Option<gateway_admin::model::system::SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError> {
use gateway_admin::model::system::{SystemUpdateChannel, SystemUpdatePolicy};
Ok(SystemUpdateDetail {
policy: SystemUpdatePolicy {
channel: channel.unwrap_or(SystemUpdateChannel::Stable),
available_channels: vec![SystemUpdateChannel::Stable, SystemUpdateChannel::Beta],
},
current_version: "0.1.0".into(),
latest_version: "0.2.0".into(),
has_update: true,
deployment_mode: "docker".into(),
build_type: "release".into(),
release_url: None,
notes: None,
cached: false,
update_supported: true,
unsupported_reason: None,
warning: None,
})
}
fn update_events(&self) -> SystemUpdateEventStream {
@@ -147,6 +168,7 @@ impl SystemOperations for ProgressSystem {
async fn perform_update(
&self,
target: Option<String>,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Ok(SystemOperationAccepted::Update {
@@ -186,3 +208,39 @@ impl SystemOperations for ProgressSystem {
Err(unavailable_system())
}
}
#[tokio::test]
async fn update_detail_should_validate_and_forward_the_temporary_channel() {
let fixture = AdminTestFixture::with_system(Arc::new(ProgressSystem)).await;
fixture.auth.insert_session("valid-session");
let router = app(fixture.state());
for (authenticated, query, expected) in [
(false, "channel=beta", StatusCode::UNAUTHORIZED),
(true, "channel=nightly", StatusCode::BAD_REQUEST),
(true, "channel=beta&extra=true", StatusCode::BAD_REQUEST),
(true, "channel=beta", StatusCode::OK),
(true, "", StatusCode::OK),
] {
let mut request = Request::builder()
.uri(format!("/api/admin/system/update/detail?{query}"))
.header("x-request-id", "req_channel_check");
if authenticated {
request = request.header(header::COOKIE, "cpr_session=valid-session");
}
let response = router
.clone()
.oneshot(request.body(Body::empty()).expect("request"))
.await
.expect("response");
assert_eq!(response.status(), expected);
if expected == StatusCode::OK {
let body: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.expect("body"))
.expect("json");
assert_eq!(
body["data"]["policy"]["channel"],
if query.is_empty() { "stable" } else { "beta" }
);
}
}
}
+7 -1
View File
@@ -93,6 +93,7 @@ impl SystemOperations for VersionSystem {
build_time: "internal-build-time".to_owned(),
deployment_mode: "binary".to_owned(),
update_channel: "release".to_owned(),
latest_version: "3.8.0".to_owned(),
has_update: true,
update_cached: true,
@@ -100,7 +101,11 @@ impl SystemOperations for VersionSystem {
})
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
async fn update_detail(
&self,
_: bool,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError> {
unreachable!("client route must not request update details")
}
@@ -111,6 +116,7 @@ impl SystemOperations for VersionSystem {
async fn perform_update(
&self,
_: Option<String>,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
_: std::sync::Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
unreachable!("client route must not perform updates")
@@ -0,0 +1,103 @@
//! 安装文件指纹用于区分本进程安装的候选与外部重新部署,历史版本号不代表磁盘事实。
use std::fs;
use std::io::{self, Read};
use std::path::Path;
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use super::swap::backup_path_for;
use super::{OperationError, SystemUpdateConfig, conflict, internal};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReleaseFiles {
binary: String,
web: String,
official_plugins: String,
}
impl ReleaseFiles {
pub(crate) fn installed(config: &SystemUpdateConfig) -> Result<Self, OperationError> {
Self::read(
&config.executable_path()?,
config.web_dist_dir()?,
&config.official_plugins_dir()?,
)
}
pub(crate) fn backup(config: &SystemUpdateConfig) -> Result<Self, OperationError> {
Self::read(
&backup_path_for(&config.executable_path()?),
&backup_path_for(config.web_dist_dir()?),
&backup_path_for(&config.official_plugins_dir()?),
)
}
fn read(binary: &Path, web: &Path, plugins: &Path) -> Result<Self, OperationError> {
if !web.join("index.html").is_file()
|| !plugins.join(super::OFFICIAL_PLUGIN_MANIFEST).is_file()
{
return Err(conflict("安装文件不完整,请核对部署后重试"));
}
let mut binary_hash = Sha256::new();
hash_file(binary, &mut binary_hash).map_err(read_error)?;
Ok(Self {
binary: hex::encode(binary_hash.finalize()),
web: hash_directory(web).map_err(read_error)?,
official_plugins: hash_directory(plugins).map_err(read_error)?,
})
}
}
fn read_error(error: io::Error) -> OperationError {
tracing::warn!(error = %error, "核对系统安装文件失败");
internal("无法核对安装文件,请检查文件完整性与读取权限")
}
fn hash_directory(path: &Path) -> io::Result<String> {
let mut hash = Sha256::new();
hash_tree(path, &mut hash)?;
Ok(hex::encode(hash.finalize()))
}
fn hash_tree(path: &Path, hash: &mut Sha256) -> io::Result<()> {
let mut entries = fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
entries.sort_by_key(fs::DirEntry::file_name);
for entry in entries {
let name = entry.file_name();
let name = name.as_encoded_bytes();
hash.update((name.len() as u64).to_le_bytes());
hash.update(name);
if entry.file_type()?.is_dir() {
hash.update(b"directory");
hash_tree(&entry.path(), hash)?;
hash.update(b"end-directory");
} else {
hash.update(b"file");
hash_file(&entry.path(), hash)?;
}
}
Ok(())
}
fn hash_file(path: &Path, hash: &mut Sha256) -> io::Result<()> {
let metadata = fs::symlink_metadata(path)?;
if !metadata.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"发行文件必须为普通文件",
));
}
hash.update(metadata.len().to_le_bytes());
let mut file = fs::File::open(path)?;
let mut buffer = [0; 64 * 1024];
loop {
let count = file.read(&mut buffer)?;
if count == 0 {
return Ok(());
}
hash.update(&buffer[..count]);
}
}
@@ -2,6 +2,7 @@
mod archive;
mod download;
mod installation;
mod process;
mod release;
mod state;
@@ -17,8 +18,9 @@ use std::time::Duration;
use async_trait::async_trait;
use chrono::Utc;
use gateway_admin::model::system::{
SystemOperationAccepted, SystemOperationKind, SystemUpdateDetail, SystemUpdateEvent,
SystemUpdateEventLevel, SystemUpdateStatus, SystemVersion,
SystemOperationAccepted, SystemOperationKind, SystemUpdateChannel as UpdateChannel,
SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel, SystemUpdatePolicy,
SystemUpdateStatus, SystemVersion,
};
use gateway_admin::ports::system::{
SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateCandidate,
@@ -32,14 +34,15 @@ use crate::config::ConfigError;
use self::archive::extract_release;
use self::download::{MAX_CHECKSUM_SIZE, MAX_DOWNLOAD_SIZE, download_file, verify_checksum};
use self::installation::ReleaseFiles;
use self::process::{environment_value, spawn_replacement};
use self::release::{
ReleaseCache, UpdateChannel, confirmed_target, detail_from_release, fetch_latest,
select_archive, validate_update_target, version_channel,
ReleaseCache, confirmed_target, detail_from_release, fetch_latest, select_archive,
validate_update_target, version_channel,
};
use self::state::{
OperationFileLock, UpdateOperation, UpdateTempDir, finish, operation_id, read_status,
recover_interrupted, set_running,
reconcile_installation, recover_interrupted, set_running,
};
use self::swap::{replace_release_files, rollback_official_plugins_dir, rollback_release};
@@ -233,23 +236,28 @@ pub struct ProcessSystemOperations {
config: Arc<SystemUpdateConfig>,
operation_lock: Arc<AsyncMutex<()>>,
release_cache: Arc<ReleaseCache>,
running_files: Arc<Result<ReleaseFiles, OperationError>>,
}
impl ProcessSystemOperations {
#[must_use]
pub fn new(cancellation: CancellationToken, config: SystemUpdateConfig) -> Self {
// 组合根在接收请求前创建服务,记录本次启动的发行文件,后续替换不能改写这份事实。
let running_files = Arc::new(ReleaseFiles::installed(&config));
Self {
cancellation,
events: Arc::new(UpdateEvents::default()),
config: Arc::new(config),
operation_lock: Arc::new(AsyncMutex::new(())),
release_cache: Arc::new(ReleaseCache::default()),
running_files,
}
}
fn start_update(
&self,
target_version: Option<String>,
channel: Option<UpdateChannel>,
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, OperationError> {
let operation_lock = Arc::clone(&self.operation_lock)
@@ -264,13 +272,14 @@ impl ProcessSystemOperations {
return Err(conflict(reason));
}
let target = confirmed_target(target_version)?;
if let Err(error) = validate_update_target(&self.config.version, &target) {
let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?;
let selected = self.resolve_channel(channel)?;
if let Err(error) = validate_update_target(&self.config.version, &target, selected) {
self.events
.error_terminal(None, Some("preflight"), error.to_string());
return Err(error);
}
let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?;
let status = read_status(&self.config.update_state_file, &self.config.version)?;
let status = self.reconcile_installation()?;
if status.need_restart {
return Err(conflict("更新已完成,请先重启服务"));
}
@@ -295,14 +304,14 @@ impl ProcessSystemOperations {
let result = tokio::select! {
biased;
() = service.cancellation.cancelled() => Err(conflict("服务关闭,更新已中断")),
result = service.perform_update_inner(&target, &operation_id, preflight.as_ref()) => result,
result = service.perform_update_inner(&target, selected, &operation_id, preflight.as_ref()) => result,
};
if let Err(error) = operation.complete(&result) {
tracing::warn!(error = %error, "系统更新终态落盘失败");
return;
}
match result {
Ok(()) => service.events.success_terminal(
Ok(_) => service.events.success_terminal(
Some(&operation_id),
Some("done"),
"更新文件已替换,等待服务重启生效",
@@ -320,9 +329,10 @@ impl ProcessSystemOperations {
async fn perform_update_inner(
&self,
target: &str,
channel: UpdateChannel,
operation_id: &str,
preflight: &dyn SystemUpdatePreflight,
) -> Result<(), OperationError> {
) -> Result<ReleaseFiles, OperationError> {
let repository = self
.config
.update_repository
@@ -337,10 +347,11 @@ impl ProcessSystemOperations {
&self.config.github_api_base,
repository,
&self.config.version,
channel,
)
.await?
.ok_or_else(|| conflict("当前发行通道没有可用更新"))?;
let detail = detail_from_release(&self.config, &release);
let detail = detail_from_release(&self.config, &release, channel);
if detail.latest_version != target {
return Err(conflict("远端最新版本已变化,请重新确认"));
}
@@ -362,7 +373,7 @@ impl ProcessSystemOperations {
version: &str,
operation_id: &str,
preflight: &dyn SystemUpdatePreflight,
) -> Result<(), OperationError> {
) -> Result<ReleaseFiles, OperationError> {
self.events.info(
Some(operation_id),
Some("asset"),
@@ -461,24 +472,46 @@ impl ProcessSystemOperations {
if let Err(error) = preflight.confirm_revision(plugin_revision).await {
return Err(applied.rollback(error));
}
let files = ReleaseFiles::installed(&self.config)?;
applied.commit();
self.events
.success(Some(operation_id), Some("replace"), "应用文件替换完成");
Ok(())
Ok(files)
}
fn resolve_channel(
&self,
requested: Option<UpdateChannel>,
) -> Result<UpdateChannel, OperationError> {
let channel = requested.unwrap_or_else(|| {
version_channel(&self.config.version).unwrap_or(UpdateChannel::Stable)
});
if !update_policy(&self.config, channel)
.available_channels
.contains(&channel)
{
return Err(conflict("当前发行版本不支持此更新通道"));
}
Ok(channel)
}
fn reconcile_installation(&self) -> Result<SystemUpdateStatus, OperationError> {
let running_files = self.running_files.as_ref().as_ref().map_err(Clone::clone)?;
reconcile_installation(&self.config, running_files)
}
}
#[async_trait]
impl SystemOperations for ProcessSystemOperations {
async fn version(&self) -> Result<SystemVersion, OperationError> {
let detail = self.update_detail(false).await?;
let detail = self.update_detail(false, None).await?;
Ok(SystemVersion {
version: self.config.version.clone(),
git_sha: self.config.git_sha.clone(),
build_time: self.config.build_time.clone(),
deployment_mode: self.config.deployment_mode.clone(),
update_channel: version_channel(&self.config.version)
.map(|channel| channel.label().to_owned())
.map(|channel| channel.as_str().to_owned())
.unwrap_or_else(|| "unknown".to_owned()),
latest_version: detail.latest_version,
has_update: detail.has_update,
@@ -487,11 +520,21 @@ impl SystemOperations for ProcessSystemOperations {
})
}
async fn update_detail(&self, refresh: bool) -> Result<SystemUpdateDetail, OperationError> {
match self.release_cache.detail(&self.config, refresh).await {
async fn update_detail(
&self,
refresh: bool,
channel: Option<UpdateChannel>,
) -> Result<SystemUpdateDetail, OperationError> {
let channel = self.resolve_channel(channel)?;
match self
.release_cache
.detail(&self.config, refresh, channel)
.await
{
Ok(detail) => Ok(detail),
Err(error) => Ok(base_update_detail(
&self.config,
channel,
self.config.update_support_error(),
Some(error.to_string()),
)),
@@ -520,9 +563,10 @@ impl SystemOperations for ProcessSystemOperations {
async fn perform_update(
&self,
target_version: Option<String>,
channel: Option<UpdateChannel>,
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, OperationError> {
self.start_update(target_version, preflight)
self.start_update(target_version, channel, preflight)
}
async fn update_status(&self) -> Result<SystemUpdateStatus, OperationError> {
@@ -531,8 +575,15 @@ impl SystemOperations for ProcessSystemOperations {
&self.config.update_state_file,
&self.config.update_lock_file,
)?;
if self.config.update_support_error().is_none() {
match OperationFileLock::acquire(&self.config.update_lock_file) {
Ok(_lock) => return self.reconcile_installation(),
Err(error) if error.kind() == SystemOperationErrorKind::Conflict => {}
Err(error) => return Err(error),
}
}
}
read_status(&self.config.update_state_file, &self.config.version)
read_status(&self.config.update_state_file, false)
}
async fn rollback(
@@ -548,7 +599,8 @@ impl SystemOperations for ProcessSystemOperations {
}
let operation_id = operation_id("rollback");
let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?;
let target_version = read_status(&self.config.update_state_file, &self.config.version)?
let target_version = self
.reconcile_installation()?
.previous_version
.ok_or_else(|| conflict("没有可用于回滚的上一版本"))?;
let release_manifest =
@@ -592,6 +644,7 @@ impl SystemOperations for ProcessSystemOperations {
&operation_id,
SystemOperationKind::Rollback,
None,
None,
result.as_ref().err().map(ToString::to_string),
)?;
match &result {
@@ -700,12 +753,26 @@ impl Drop for AppliedReleaseGuard<'_> {
}
}
fn update_policy(config: &SystemUpdateConfig, channel: UpdateChannel) -> SystemUpdatePolicy {
use UpdateChannel::{Alpha, Beta, Experimental, Rc, Stable};
SystemUpdatePolicy {
channel,
available_channels: if version_channel(&config.version) == Some(Experimental) {
vec![Experimental]
} else {
vec![Stable, Rc, Beta, Alpha]
},
}
}
fn base_update_detail(
config: &SystemUpdateConfig,
channel: UpdateChannel,
unsupported_reason: Option<String>,
warning: Option<String>,
) -> SystemUpdateDetail {
SystemUpdateDetail {
policy: update_policy(config, channel),
current_version: config.version.clone(),
latest_version: config.version.clone(),
has_update: false,
@@ -1,9 +1,10 @@
//! GitHub Release 发现、缓存、版本比较与下载信任边界。
use std::env;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use gateway_admin::model::system::SystemUpdateDetail;
use gateway_admin::model::system::{SystemUpdateChannel as UpdateChannel, SystemUpdateDetail};
use serde::Deserialize;
use tokio::sync::Mutex;
@@ -12,27 +13,6 @@ use super::{OperationError, SystemUpdateConfig, conflict, invalid, upstream};
const APP_BINARY_NAME: &str = "codex-proxy-rs";
const CACHE_TTL: Duration = Duration::from_secs(20 * 60);
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum UpdateChannel {
Stable,
Alpha,
Beta,
Rc,
Experimental,
}
impl UpdateChannel {
pub(crate) fn label(&self) -> &str {
match self {
Self::Stable => "stable",
Self::Alpha => "alpha",
Self::Beta => "beta",
Self::Rc => "rc",
Self::Experimental => "exp",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct GitHubRelease {
pub(crate) tag_name: String,
@@ -62,6 +42,7 @@ struct CachedRelease {
#[derive(Default)]
pub(crate) struct ReleaseCache {
entry: Mutex<Option<CachedRelease>>,
request_sequence: AtomicU64,
}
impl ReleaseCache {
@@ -69,9 +50,15 @@ impl ReleaseCache {
&self,
config: &SystemUpdateConfig,
refresh: bool,
channel: UpdateChannel,
) -> Result<SystemUpdateDetail, OperationError> {
if let Some(reason) = config.update_support_error() {
return Ok(super::base_update_detail(config, Some(reason), None));
return Ok(super::base_update_detail(
config,
channel,
Some(reason),
None,
));
}
let repository = config
.update_repository
@@ -79,26 +66,50 @@ impl ReleaseCache {
.ok_or_else(|| conflict("update repository is not configured"))?;
validate_repository(repository)?;
validate_api_base(&config.github_api_base).map_err(conflict)?;
let key = config.release_cache_key();
let key = format!("{}|{}", config.release_cache_key(), channel.as_str());
if !refresh && let Some(detail) = self.cached(&key).await {
return Ok(detail);
}
match fetch_latest(&config.github_api_base, repository, &config.version).await {
let sequence = self.request_sequence.fetch_add(1, Ordering::Relaxed) + 1;
match fetch_latest(
&config.github_api_base,
repository,
&config.version,
channel,
)
.await
{
Ok(release) => {
let detail = release.as_ref().map_or_else(
|| super::base_update_detail(config, config.update_support_error(), None),
|release| detail_from_release(config, release),
|| {
super::base_update_detail(
config,
channel,
config.update_support_error(),
None,
)
},
|release| detail_from_release(config, release, channel),
);
*self.entry.lock().await = Some(CachedRelease {
key,
detail: detail.clone(),
cached_at: Instant::now(),
});
let mut entry = self.entry.lock().await;
// 较慢的旧请求不能覆盖或清除新检查的结果,包括切走再切回同一通道。
if self.request_sequence.load(Ordering::Relaxed) == sequence {
*entry = Some(CachedRelease {
key,
detail: detail.clone(),
cached_at: Instant::now(),
});
}
Ok(detail)
}
Err(error) => {
// 强制检查失败后清掉旧结果,避免后续版本查询再次显示旧的更新标记。
*self.entry.lock().await = None;
let mut entry = self.entry.lock().await;
if self.request_sequence.load(Ordering::Relaxed) == sequence
&& entry.as_ref().is_some_and(|cached| cached.key == key)
{
*entry = None;
}
Err(error)
}
}
@@ -120,6 +131,7 @@ pub(crate) async fn fetch_latest(
api_base: &str,
repository: &str,
current_version: &str,
channel: UpdateChannel,
) -> Result<Option<GitHubRelease>, OperationError> {
validate_api_base(api_base).map_err(conflict)?;
validate_repository(repository)?;
@@ -143,7 +155,7 @@ pub(crate) async fn fetch_latest(
.await?;
let last_page = releases.len() < 100;
for release in releases {
let Some(version) = eligible_release_version(&release, &current) else {
let Some(version) = eligible_release_version(&release, &current, channel) else {
continue;
};
if latest
@@ -163,11 +175,12 @@ pub(crate) async fn fetch_latest(
fn eligible_release_version(
release: &GitHubRelease,
current: &semver::Version,
channel: UpdateChannel,
) -> Option<semver::Version> {
let version = semver::Version::parse(&normalize_version(&release.tag_name)).ok()?;
(!release.draft
&& release.prerelease != version.pre.is_empty()
&& (version == *current || update_target_allowed(current, &version)))
&& (version == *current || update_target_allowed(current, &version, channel)))
.then_some(version)
}
@@ -197,16 +210,18 @@ async fn fetch_release_json<T: serde::de::DeserializeOwned>(
pub(crate) fn detail_from_release(
config: &SystemUpdateConfig,
release: &GitHubRelease,
channel: UpdateChannel,
) -> SystemUpdateDetail {
let unsupported_reason = config.update_support_error();
let latest_version = normalize_version(&release.tag_name);
let has_update = validate_update_target(&config.version, &latest_version).is_ok();
let has_update = validate_update_target(&config.version, &latest_version, channel).is_ok();
if unsupported_reason.is_some()
|| (!has_update && latest_version != normalize_version(&config.version))
{
return super::base_update_detail(config, unsupported_reason, None);
return super::base_update_detail(config, channel, unsupported_reason, None);
}
SystemUpdateDetail {
policy: super::update_policy(config, channel),
current_version: config.version.clone(),
latest_version,
has_update,
@@ -366,9 +381,12 @@ pub(crate) fn version_channel(version: &str) -> Option<UpdateChannel> {
}
/// 检查更新与执行更新共用同一规则;构建元数据不构成更新。
fn update_target_allowed(current: &semver::Version, target: &semver::Version) -> bool {
fn update_target_allowed(
current: &semver::Version,
target: &semver::Version,
selected: UpdateChannel,
) -> bool {
use UpdateChannel::{Alpha, Beta, Experimental, Rc, Stable};
if current.major != target.major || !target.cmp_precedence(current).is_gt() {
return false;
}
@@ -377,30 +395,34 @@ fn update_target_allowed(current: &semver::Version, target: &semver::Version) ->
else {
return false;
};
if current_channel == Stable {
return target_channel == Stable;
}
// 预发行只承接本轮目标版本;exp 的相同基线也只代表同一轮实验。
if (current.minor, current.patch) != (target.minor, target.patch) {
return false;
if current_channel == Experimental || selected == Experimental || target_channel == Experimental
{
return current_channel == Experimental
&& selected == Experimental
&& target_channel == Experimental
&& (current.minor, current.patch) == (target.minor, target.patch);
}
matches!(
(current_channel, target_channel),
(Alpha, Alpha | Beta | Rc | Stable)
| (Beta, Beta | Rc | Stable)
(selected, target_channel),
(Stable, Stable)
| (Rc, Rc | Stable)
| (Experimental, Experimental)
| (Beta, Beta | Rc | Stable)
| (Alpha, Alpha | Beta | Rc | Stable)
)
}
pub(crate) fn validate_update_target(current: &str, target: &str) -> Result<(), OperationError> {
pub(crate) fn validate_update_target(
current: &str,
target: &str,
channel: UpdateChannel,
) -> Result<(), OperationError> {
let current = semver::Version::parse(&normalize_version(current))
.map_err(|_| conflict("当前版本格式无效"))?;
let target = semver::Version::parse(&normalize_version(target))
.map_err(|_| invalid("target version is invalid"))?;
if !update_target_allowed(&current, &target) {
if !update_target_allowed(&current, &target, channel) {
return Err(conflict(
"当前版本不允许更新到此目标:不支持跨通道、跨发行线、跨大版本或降级",
"所选通道不允许此目标,或目标涉及跨实验线、跨大版本、降级",
));
}
Ok(())
@@ -12,7 +12,8 @@ use gateway_admin::model::system::{
};
use serde::{Deserialize, Serialize};
use super::{OperationError, conflict, internal};
use super::installation::ReleaseFiles;
use super::{OperationError, SystemUpdateConfig, conflict, internal};
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -20,6 +21,10 @@ struct PersistedState {
previous_version: Option<String>,
current_version: Option<String>,
#[serde(default)]
current_files: Option<ReleaseFiles>,
#[serde(default)]
previous_files: Option<ReleaseFiles>,
#[serde(default)]
operation: PersistedOperation,
}
@@ -190,13 +195,14 @@ impl UpdateOperation {
pub(crate) fn complete(
&mut self,
result: &Result<(), OperationError>,
result: &Result<ReleaseFiles, OperationError>,
) -> Result<(), OperationError> {
finish(
&self.path,
&self.operation_id,
SystemOperationKind::Update,
result.as_ref().ok().map(|()| self.target.clone()),
result.as_ref().ok().map(|_| self.target.clone()),
result.as_ref().ok().cloned(),
result.as_ref().err().map(ToString::to_string),
)?;
self.completed = true;
@@ -212,6 +218,7 @@ impl Drop for UpdateOperation {
&self.operation_id,
SystemOperationKind::Update,
None,
None,
Some("更新任务已中断,请重新发起更新".to_owned()),
)
{
@@ -237,6 +244,7 @@ pub(crate) fn recover_interrupted(path: &Path, lock_path: &Path) -> Result<(), O
&operation_id,
kind.into(),
None,
None,
Some("更新任务已中断,请重新发起更新".to_owned()),
)?;
}
@@ -281,6 +289,7 @@ pub(crate) fn finish(
operation_id: &str,
kind: SystemOperationKind,
version: Option<String>,
files: Option<ReleaseFiles>,
error: Option<String>,
) -> Result<(), OperationError> {
let mut state = read_persisted(path)?;
@@ -299,11 +308,14 @@ pub(crate) fn finish(
SystemOperationKind::Update => {
state.previous_version = state.current_version.take();
state.current_version = version.clone();
state.previous_files = state.current_files.take();
state.current_files = files;
}
SystemOperationKind::Rollback => {
let current = state.current_version.take();
state.current_version = state.previous_version.take();
state.previous_version = current;
std::mem::swap(&mut state.current_files, &mut state.previous_files);
}
SystemOperationKind::Restart => {}
}
@@ -315,15 +327,12 @@ pub(crate) fn finish(
pub(crate) fn read_status(
path: &Path,
running_version: &str,
need_restart: bool,
) -> Result<SystemUpdateStatus, OperationError> {
let state = read_persisted(path)?;
let operation = state.operation;
Ok(SystemUpdateStatus {
need_restart: matches!(operation.status, PersistedStatus::Succeeded)
&& state.current_version.as_deref().is_some_and(|version| {
version.trim_start_matches('v') != running_version.trim_start_matches('v')
}),
need_restart,
previous_version: state.previous_version,
current_version: state.current_version,
operation: SystemOperationState {
@@ -339,6 +348,52 @@ pub(crate) fn read_status(
})
}
/// 调用方必须持有进程内锁及文件锁,避免将安装中间态当成手动部署。
pub(crate) fn reconcile_installation(
config: &SystemUpdateConfig,
running: &ReleaseFiles,
) -> Result<SystemUpdateStatus, OperationError> {
let path = &config.update_state_file;
let mut state = read_persisted(path)?;
let installed = ReleaseFiles::installed(config)?;
let need_restart = &installed != running;
if need_restart
&& (state.current_files.as_ref() != Some(&installed) || state.current_version.is_none())
{
return Err(conflict(
"运行期间安装文件被外部修改,请核对部署并重启服务后重试",
));
}
let mut changed = false;
if !need_restart
&& (state.current_files.as_ref() != Some(&installed)
|| state.current_version.as_deref() != Some(config.version.as_str()))
{
// 保留操作历史,但外部部署的文件不能继续使用旧操作推断版本。
state.current_version = Some(config.version.clone());
state.current_files = Some(installed);
changed = true;
}
if state.previous_version.is_some() || state.previous_files.is_some() {
let backup_valid = state.previous_files.as_ref().is_some_and(|expected| {
ReleaseFiles::backup(config)
.as_ref()
.is_ok_and(|actual| actual == expected)
});
if !backup_valid {
// 无法证明旧备份完整时撤销回滚资格,不删除用户的备份文件。
state.previous_version = None;
state.previous_files = None;
changed = true;
}
}
if changed {
write_persisted(path, &state)?;
}
// 指纹证明磁盘仍是本进程安装的候选,回滚到旧版本也必须等待重启。
read_status(path, need_restart)
}
pub(crate) fn default_temp_dir(state_file: &Path) -> PathBuf {
state_file
.parent()
@@ -393,7 +393,7 @@ fn copy_dir_all(from: &Path, to: &Path) -> io::Result<()> {
Ok(())
}
fn backup_path_for(path: &Path) -> PathBuf {
pub(crate) fn backup_path_for(path: &Path) -> PathBuf {
let mut backup = path.as_os_str().to_os_string();
backup.push(".backup");
PathBuf::from(backup)
@@ -0,0 +1,292 @@
use super::*;
use gateway_admin::model::system::SystemUpdateChannel::{Alpha, Beta, Experimental, Rc, Stable};
#[tokio::test]
async fn temporary_channel_should_filter_future_releases_without_changing_running_channel() {
let server = MockServer::start().await;
let fixture = Fixture::new();
let releases: Vec<_> = ["1.1.0", "1.2.0-rc.1", "1.3.0-beta.1", "1.4.0-alpha.1", "1.9.0-exp.9", "2.0.0"]
.into_iter().map(|version| serde_json::json!({"tag_name": format!("v{version}"), "prerelease": version.contains('-')})).collect();
Mock::given(method("GET"))
.and(path("/repos/owner/repository/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(releases))
.mount(&server)
.await;
let service = fixture.service(&server);
for (channel, target) in [
(Stable, "1.1.0"),
(Rc, "1.2.0-rc.1"),
(Beta, "1.3.0-beta.1"),
(Alpha, "1.4.0-alpha.1"),
(Stable, "1.1.0"),
] {
let detail = service
.update_detail(false, Some(channel))
.await
.expect("detail");
assert_eq!(detail.latest_version, target);
assert_eq!(detail.policy.channel, channel);
assert_eq!(
detail.policy.available_channels,
vec![Stable, Rc, Beta, Alpha]
);
assert!(detail.has_update);
let cached = service
.update_detail(false, Some(channel))
.await
.expect("cached");
assert!(cached.cached);
assert_eq!(cached.latest_version, target);
}
let version = service.version().await.expect("default check");
assert_eq!(version.update_channel, "stable");
assert_eq!(version.latest_version, "1.1.0");
assert!(!fixture.state().exists(), "检查通道不能写入实例偏好");
let requests = server.received_requests().await.expect("requests");
assert_eq!(requests.len(), 5);
assert!(
requests
.iter()
.all(|request| request.url.path().ends_with("/releases"))
);
}
#[tokio::test]
async fn reopening_should_infer_running_channel_and_switching_to_stable_should_not_downgrade() {
let server = MockServer::start().await;
let fixture = Fixture::new();
Mock::given(method("GET"))
.and(path("/repos/owner/repository/releases"))
.respond_with(release_response("1.0.0", Vec::new()))
.mount(&server)
.await;
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = "1.1.0-beta.1".into();
let service = ProcessSystemOperations::new(CancellationToken::new(), config.clone());
let temporary = service
.update_detail(true, Some(Stable))
.await
.expect("view stable");
assert_eq!(temporary.policy.channel, Stable);
assert!(!temporary.has_update);
assert_eq!(temporary.latest_version, "1.1.0-beta.1");
assert_eq!(
service
.update_detail(false, None)
.await
.expect("reopened")
.policy
.channel,
Beta
);
let restarted = ProcessSystemOperations::new(CancellationToken::new(), config.clone());
assert_eq!(
restarted
.update_detail(false, None)
.await
.expect("restarted")
.policy
.channel,
Beta
);
config.version = "1.1.0".into();
let promoted = ProcessSystemOperations::new(CancellationToken::new(), config);
assert_eq!(
promoted
.update_detail(false, None)
.await
.expect("now stable")
.policy
.channel,
Stable
);
assert!(!fixture.state().exists());
}
#[tokio::test]
async fn install_should_use_confirmed_channel_and_infer_new_default_only_after_restart() {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&server,
"1.1.0-beta.1",
ArchiveKind::Safe,
ChecksumKind::Valid,
)
.await;
let service = fixture.service(&server);
let error = service
.perform_update(
Some("1.1.0-beta.1".into()),
Some(Stable),
Arc::new(AllowingUpdatePreflight),
)
.await
.expect_err("target outside confirmed channel");
assert_eq!(error.kind(), SystemOperationErrorKind::Conflict);
assert!(
server
.received_requests()
.await
.expect("requests")
.is_empty()
);
service
.perform_update(
Some("1.1.0-beta.1".into()),
Some(Beta),
Arc::new(AllowingUpdatePreflight),
)
.await
.expect("accept selected target");
let status = wait_for_update(&service).await;
assert_eq!(status.operation.status, SystemOperationStatus::Succeeded);
assert!(status.need_restart);
assert_eq!(
service
.version()
.await
.expect("still running stable")
.update_channel,
"stable"
);
assert!(
service
.perform_update(
Some("1.1.0-beta.1".into()),
Some(Alpha),
Arc::new(AllowingUpdatePreflight)
)
.await
.is_err()
);
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = "1.1.0-beta.1".into();
let restarted = ProcessSystemOperations::new(CancellationToken::new(), config);
assert!(
!restarted
.update_status()
.await
.expect("restarted")
.need_restart
);
assert_eq!(
restarted
.update_detail(false, None)
.await
.expect("new default")
.policy
.channel,
Beta
);
}
#[tokio::test]
async fn temporary_channels_should_keep_experimental_isolation() {
let server = MockServer::start().await;
let fixture = Fixture::new();
let service = fixture.service(&server);
assert!(
service
.update_detail(true, Some(Experimental))
.await
.is_err()
);
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = "1.1.0-exp.1".into();
config.build_type = "experimental".into();
let experimental = ProcessSystemOperations::new(CancellationToken::new(), config);
for channel in [Stable, Rc, Beta, Alpha] {
assert!(
experimental
.update_detail(true, Some(channel))
.await
.is_err()
);
}
assert!(
server
.received_requests()
.await
.expect("requests")
.is_empty()
);
}
#[tokio::test]
async fn slower_check_should_not_overwrite_newer_cached_candidate() {
let server = MockServer::start().await;
let fixture = Fixture::new();
Mock::given(method("GET"))
.and(path("/repos/owner/repository/releases"))
.respond_with(release_response("1.1.0", Vec::new()).set_delay(Duration::from_millis(150)))
.up_to_n_times(1)
.with_priority(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/owner/repository/releases"))
.respond_with(release_response("1.2.0", Vec::new()))
.mount(&server)
.await;
let service = fixture.service(&server);
let first_service = service.clone();
let first = tokio::spawn(async move { first_service.update_detail(true, None).await });
tokio::time::timeout(Duration::from_secs(1), async {
while server
.received_requests()
.await
.expect("requests")
.is_empty()
{
tokio::task::yield_now().await;
}
})
.await
.expect("first request sent");
assert_eq!(
service
.update_detail(true, None)
.await
.expect("new check")
.latest_version,
"1.2.0"
);
assert_eq!(
first
.await
.expect("join")
.expect("old check")
.latest_version,
"1.1.0"
);
let cached = service.update_detail(false, None).await.expect("cache");
assert!(cached.cached);
assert_eq!(cached.latest_version, "1.2.0");
}
#[tokio::test]
async fn inferred_prerelease_policy_should_follow_future_minor_and_patch_releases() {
for (current, target) in [
("3.12.0-alpha.1", "3.13.0-alpha.2"),
("3.12.0-beta.1", "3.12.1-rc.1"),
("3.12.0-rc.1", "3.13.0"),
] {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(&server, target, ArchiveKind::Safe, ChecksumKind::Valid)
.await;
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = current.to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true, None).await.expect("check");
assert!(detail.has_update);
assert_eq!(detail.latest_version, target);
assert_eq!(
complete_update(&service, target).await.operation.status,
SystemOperationStatus::Succeeded
);
}
}
@@ -0,0 +1,148 @@
use super::*;
#[tokio::test]
async fn manual_image_replacement_should_clear_pending_state_and_invalid_backup() {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&server,
TARGET_VERSION,
ArchiveKind::Safe,
ChecksumKind::Valid,
)
.await;
let service = fixture.service(&server);
assert!(complete_update(&service, TARGET_VERSION).await.need_restart);
// 模拟重建镜像:数据卷保留,应用文件和容器内备份被重新替换。
fixture.write_executable("manually-installed-binary");
fs::remove_file(fixture.root.path().join("codex-proxy-rs.backup"))
.expect("remove image backup");
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = "1.1.0".into();
config.deployment_mode = "docker".into();
let redeployed = ProcessSystemOperations::new(CancellationToken::new(), config);
let status = redeployed.update_status().await.expect("reconciled");
assert!(!status.need_restart);
assert_eq!(status.current_version.as_deref(), Some("1.1.0"));
assert!(status.previous_version.is_none());
assert_eq!(
status.operation.target_version.as_deref(),
Some(TARGET_VERSION)
);
assert!(
redeployed
.update_detail(true, None)
.await
.expect("check")
.has_update
);
assert_eq!(
complete_update(&redeployed, TARGET_VERSION)
.await
.operation
.status,
SystemOperationStatus::Succeeded
);
}
#[tokio::test]
async fn rollback_after_restart_should_require_restart_even_for_a_lower_version() {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&server,
TARGET_VERSION,
ArchiveKind::Safe,
ChecksumKind::Valid,
)
.await;
let service = fixture.service(&server);
assert!(complete_update(&service, TARGET_VERSION).await.need_restart);
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = TARGET_VERSION.into();
let restarted = ProcessSystemOperations::new(CancellationToken::new(), config);
let status = restarted.update_status().await.expect("new process");
assert!(!status.need_restart);
assert_eq!(status.previous_version.as_deref(), Some("1.0.0"));
restarted
.rollback(Arc::new(AllowingUpdatePreflight))
.await
.expect("rollback");
let status = restarted.update_status().await.expect("pending rollback");
assert!(status.need_restart);
assert_eq!(status.current_version.as_deref(), Some("1.0.0"));
assert!(
!fixture
.service(&server)
.update_status()
.await
.expect("rollback restarted")
.need_restart
);
}
#[tokio::test]
async fn external_change_during_runtime_should_not_be_reported_as_a_successful_installation() {
let server = MockServer::start().await;
let fixture = Fixture::new();
let service = fixture.service(&server);
service.update_status().await.expect("baseline");
fs::write(fixture.web().join("index.html"), "unexpected-web").expect("external replacement");
assert_eq!(
service
.update_status()
.await
.expect_err("mixed deployment")
.kind(),
SystemOperationErrorKind::Conflict
);
assert!(
service
.perform_test_update(Some(TARGET_VERSION.into()))
.await
.is_err()
);
assert_eq!(
fs::read(fixture.executable()).expect("binary"),
b"old-binary"
);
}
#[tokio::test]
async fn pending_update_should_revoke_rollback_when_backup_content_changes() {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&server,
TARGET_VERSION,
ArchiveKind::Safe,
ChecksumKind::Valid,
)
.await;
let service = fixture.service(&server);
assert!(complete_update(&service, TARGET_VERSION).await.need_restart);
fs::write(
fixture.root.path().join("codex-proxy-rs.backup"),
"unrelated-backup",
)
.expect("changed backup");
let status = service
.update_status()
.await
.expect("pending with invalid backup");
assert!(status.need_restart);
assert!(status.previous_version.is_none());
assert!(
service
.rollback(Arc::new(AllowingUpdatePreflight))
.await
.is_err()
);
assert_eq!(
fs::read(fixture.executable()).expect("binary"),
b"new-binary"
);
}
@@ -1,3 +1,6 @@
mod channels;
mod installation;
use std::fs;
use std::path::PathBuf;
use std::sync::{
@@ -61,7 +64,8 @@ impl TestSystemUpdate for ProcessSystemOperations {
&self,
target: Option<String>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
SystemOperations::perform_update(self, target, Arc::new(AllowingUpdatePreflight)).await
SystemOperations::perform_update(self, target, None, Arc::new(AllowingUpdatePreflight))
.await
}
}
@@ -349,6 +353,7 @@ async fn update_should_restore_all_files_when_plugin_revision_changes_during_swa
SystemOperations::perform_update(
&service,
Some(TARGET_VERSION.to_owned()),
None,
Arc::new(ChangingRevisionPreflight {
confirmations: AtomicUsize::new(0),
}),
@@ -496,9 +501,15 @@ async fn update_detail_should_report_refresh_failure_and_clear_previous_update()
let fixture = Fixture::new();
fixture.mount_release_once(&server, TARGET_VERSION).await;
let service = fixture.service(&server);
service.update_detail(true).await.expect("prime cache");
service
.update_detail(true, None)
.await
.expect("prime cache");
let detail = service.update_detail(true).await.expect("failure detail");
let detail = service
.update_detail(true, None)
.await
.expect("failure detail");
assert!(!detail.has_update);
assert!(detail.warning.is_some());
assert!(!detail.cached);
@@ -541,7 +552,7 @@ async fn update_detail_should_offer_latest_non_draft_release_in_current_channel(
config.version = "1.1.0-beta.1".to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert_eq!(detail.latest_version, "1.1.0-beta.2");
assert!(detail.has_update);
}
@@ -554,7 +565,10 @@ async fn update_detail_should_reject_untrusted_github_api_base() {
fixture.config("https://api.github.example/repos"),
);
let detail = service.update_detail(true).await.expect("safe rejection");
let detail = service
.update_detail(true, None)
.await
.expect("safe rejection");
assert!(!detail.update_supported);
assert!(detail.warning.is_some() || detail.unsupported_reason.is_some());
}
@@ -567,7 +581,7 @@ async fn update_detail_should_withhold_updates_for_source_builds() {
config.build_type = "source".to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert_eq!(detail.latest_version, "1.0.0");
assert!(!detail.update_supported);
assert!(detail.unsupported_reason.is_some());
@@ -610,7 +624,7 @@ async fn experimental_build_should_ignore_newer_stable_and_other_experimental_re
config.build_type = "experimental".to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert_eq!(detail.latest_version, "3.10.0-exp.2");
assert!(!detail.has_update);
assert!(detail.update_supported);
@@ -620,7 +634,10 @@ async fn experimental_build_should_ignore_newer_stable_and_other_experimental_re
let version = service.version().await.expect("version");
assert!(!version.has_update);
assert_eq!(version.update_channel, "exp");
let cached = service.update_detail(false).await.expect("cached detail");
let cached = service
.update_detail(false, None)
.await
.expect("cached detail");
assert!(cached.cached);
assert!(!cached.has_update);
}
@@ -657,7 +674,7 @@ async fn experimental_update_should_find_highest_same_channel_version_across_pag
config.build_type = "experimental".to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert_eq!(detail.latest_version, "3.10.0-exp.10");
assert_eq!(detail.notes.as_deref(), Some("exp.10 notes"));
assert!(detail.has_update);
@@ -716,8 +733,6 @@ async fn update_should_reject_cross_channel_targets_before_fetching_or_replacing
("experimental", "3.10.0-exp.2", "3.10.1-exp.3"),
("experimental", "3.10.0-exp.2", "3.10.0-exp.1"),
("release", "3.10.0-beta.2", "3.10.0-alpha.3"),
("release", "3.10.0-rc.2", "3.11.0-rc.3"),
("release", "3.10.0-rc.2", "3.11.0"),
("release", "3.10.0", "3.10.0+new"),
("release", "3.10.0", "3.9.0"),
("experimental", "3.10.0-exp.2", "3.11.0-beta.1"),
@@ -737,7 +752,7 @@ async fn update_should_reject_cross_channel_targets_before_fetching_or_replacing
.await
.expect_err("cross-channel update");
assert_eq!(error.kind(), SystemOperationErrorKind::Conflict);
assert!(error.to_string().contains("跨通道"));
assert!(error.to_string().contains("所选通道"));
assert!(
server
.received_requests()
@@ -784,7 +799,7 @@ async fn update_checks_should_follow_stage_promotion_and_experimental_isolation(
config.version = current.to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert_eq!(detail.has_update, allowed, "{current} -> {target}");
assert_eq!(
detail.latest_version,
@@ -833,7 +848,10 @@ async fn update_detail_should_keep_current_release_notes_without_allowing_reinst
}
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("current release");
let detail = service
.update_detail(true, None)
.await
.expect("current release");
assert_eq!(detail.latest_version, current);
assert!(!detail.has_update);
assert!(detail.update_supported);
@@ -842,7 +860,10 @@ async fn update_detail_should_keep_current_release_notes_without_allowing_reinst
Some("## 当前版本\n\n- 修复更新状态")
);
assert_eq!(detail.release_url.as_deref(), Some(release_url.as_str()));
let cached = service.update_detail(false).await.expect("cached release");
let cached = service
.update_detail(false, None)
.await
.expect("cached release");
assert!(cached.cached);
assert_eq!(cached.notes, detail.notes);
assert_eq!(cached.release_url, detail.release_url);
@@ -886,7 +907,7 @@ async fn update_detail_should_prefer_available_update_notes_over_current_release
let detail = fixture
.service(&server)
.update_detail(true)
.update_detail(true, None)
.await
.expect("detail");
assert!(detail.has_update);
@@ -923,7 +944,7 @@ async fn update_detail_should_find_current_release_notes_across_pages() {
let detail = fixture
.service(&server)
.update_detail(true)
.update_detail(true, None)
.await
.expect("detail");
assert!(!detail.has_update);
@@ -950,7 +971,7 @@ async fn update_detail_should_not_use_draft_or_mislabeled_current_release_notes(
config.version = current.to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert!(!detail.has_update);
assert_eq!(detail.latest_version, current);
assert!(detail.notes.is_none());
@@ -959,11 +980,8 @@ async fn update_detail_should_not_use_draft_or_mislabeled_current_release_notes(
}
#[tokio::test]
async fn update_checks_should_ignore_other_cycles_downgrades_and_build_metadata() {
async fn update_checks_should_ignore_downgrades_unknown_stages_and_build_metadata() {
for (current, target) in [
("3.12.0-alpha.1", "3.13.0-alpha.2"),
("3.12.0-beta.1", "3.12.1-rc.1"),
("3.12.0-rc.1", "3.13.0"),
("3.12.0-beta.2", "3.12.0-beta.1"),
("3.12.0", "3.12.0+new-build"),
("3.12.0-beta.1+aaa", "3.12.0-beta.1+zzz"),
@@ -979,7 +997,7 @@ async fn update_checks_should_ignore_other_cycles_downgrades_and_build_metadata(
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = current.to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert!(!detail.has_update, "{current} -> {target}");
assert_eq!(detail.latest_version, current);
assert!(detail.notes.is_none());
@@ -1003,7 +1021,7 @@ async fn update_checks_should_fail_closed_for_unknown_current_channels() {
config.version = current.to_owned();
config.build_type = build_type.to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert!(!detail.has_update);
assert!(!detail.update_supported);
assert!(detail.unsupported_reason.is_some());
@@ -1036,7 +1054,7 @@ async fn stable_update_should_select_allowed_version_below_newer_major_releases(
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = "3.11.0".to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert!(detail.has_update);
assert_eq!(detail.latest_version, "3.11.10");
assert_eq!(detail.notes.as_deref(), Some("stable notes"));
@@ -1060,7 +1078,7 @@ async fn prerelease_updates_should_install_later_stages_and_stable_release() {
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
assert!(
service
.update_detail(true)
.update_detail(true, None)
.await
.expect("detail")
.has_update
@@ -1095,9 +1113,12 @@ async fn update_detail_should_use_cached_release_when_not_refreshed() {
let fixture = Fixture::new();
fixture.mount_release_once(&server, TARGET_VERSION).await;
let service = fixture.service(&server);
service.update_detail(true).await.expect("prime cache");
service
.update_detail(true, None)
.await
.expect("prime cache");
assert!(service.update_detail(false).await.is_ok());
assert!(service.update_detail(false, None).await.is_ok());
}
#[tokio::test]
@@ -1110,7 +1131,7 @@ async fn update_detail_should_withhold_cross_major_release() {
let detail = fixture
.service(&server)
.update_detail(true)
.update_detail(true, None)
.await
.expect("detail");
assert_eq!(detail.latest_version, "1.0.0");
@@ -1569,27 +1590,19 @@ async fn update_should_restore_web_assets_when_binary_backup_fails() {
}
#[tokio::test]
async fn update_status_should_read_local_update_state() {
async fn update_status_should_reconcile_manual_deployment_without_reusing_legacy_success() {
let fixture = Fixture::new();
fs::write(
fixture.state(),
r#"{"previousVersion":"1.0.0","currentVersion":"2.0.0","operation":{"operationId":"x","kind":"update","status":"succeeded","targetVersion":"2.0.0","message":"done","error":null,"startedAt":"2026-07-19T00:00:00Z","finishedAt":"2026-07-19T00:01:00Z"}}"#,
)
.expect("state");
let service = ProcessSystemOperations::new(
CancellationToken::new(),
fixture.config("https://api.github.com/repos"),
);
assert_eq!(
service
.update_status()
.await
.expect("status")
.previous_version
.as_deref(),
Some("1.0.0")
);
fs::write(fixture.state(), r#"{"previousVersion":"3.14.0","currentVersion":"3.14.1","operation":{"operationId":"legacy","kind":"update","status":"succeeded","targetVersion":"3.14.1"}}"#).expect("legacy state");
let mut config = fixture.config("https://api.github.com/repos");
config.version = "3.15.0".to_owned();
config.deployment_mode = "docker".to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let status = service.update_status().await.expect("status");
assert!(!status.need_restart);
assert_eq!(status.current_version.as_deref(), Some("3.15.0"));
assert!(status.previous_version.is_none());
assert_eq!(status.operation.target_version.as_deref(), Some("3.14.1"));
assert_eq!(status.operation.status, SystemOperationStatus::Succeeded);
}
#[tokio::test]
@@ -732,7 +732,11 @@ impl SystemOperations for UnusedAdminRuntime {
Err(unused_system())
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
async fn update_detail(
&self,
_: bool,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError> {
Err(unused_system())
}
@@ -743,6 +747,7 @@ impl SystemOperations for UnusedAdminRuntime {
async fn perform_update(
&self,
_: Option<String>,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unused_system())
@@ -12,7 +12,8 @@ const OFFICIAL_ARTIFACT_HOST: &str = "persistent.oaistatic.com";
const EOCD_SIGNATURE: &[u8; 4] = b"PK\x05\x06";
const CENTRAL_SIGNATURE: &[u8; 4] = b"PK\x01\x02";
const LOCAL_SIGNATURE: &[u8; 4] = b"PK\x03\x04";
const CORE_PATH_SUFFIX: &[u8] = b"/Contents/Resources/codex";
// Core 位于独立的 CodexCLI.app 中,bin/codex 仅为启动脚本。
const CORE_PATH_SUFFIX: &[u8] = b"/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex";
const CORE_VERSION_ANCHOR: &[u8] = b"codex-mcp-client/";
const MAX_EOCD_SEARCH_BYTES: u64 = 65_535 + 22;
const MAX_CENTRAL_DIRECTORY_BYTES: u64 = 16 * 1024 * 1024;
@@ -1,5 +1,5 @@
use provider_openai::transport::profile::desktop_artifact::{
CoreVersionScanner, find_core_entry, parse_content_range,
CodexDesktopArtifactError, CoreVersionScanner, find_core_entry, parse_content_range,
};
#[test]
@@ -52,7 +52,56 @@ fn scanner_skips_an_invalid_marker_before_the_bundled_version() {
#[test]
fn central_directory_requires_one_deflated_core_entry() {
let name = b"ChatGPT.app/Contents/Resources/codex";
let name = b"ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex";
let central = deflated_entry(name);
let entry = find_core_entry(&central, 1).expect("core entry");
assert_eq!(entry.name, name);
assert_eq!(entry.compressed_size, 100);
assert_eq!(entry.uncompressed_size, 200);
assert_eq!(entry.local_header_offset, 50);
}
#[test]
fn central_directory_finds_app_bundled_core_among_cli_helpers() {
let name = b"ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex";
let central = [
deflated_entry(b"ChatGPT.app/Contents/Resources/codex-cli/bin/codex"),
deflated_entry(name),
deflated_entry(b"ChatGPT.app/Contents/Resources/codex-cli/bin/codex-code-mode-host"),
]
.concat();
assert_eq!(
find_core_entry(&central, 3).expect("app bundled core").name,
name
);
}
#[test]
fn central_directory_rejects_duplicate_core_entries() {
let name = b"ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex";
let central = [deflated_entry(name), deflated_entry(name)].concat();
assert!(matches!(
find_core_entry(&central, 2),
Err(CodexDesktopArtifactError::MissingCore)
));
}
#[test]
fn central_directory_rejects_legacy_core_and_cli_launcher_paths() {
for name in [
b"ChatGPT.app/Contents/Resources/codex".as_slice(),
b"ChatGPT.app/Contents/Resources/codex-cli/bin/codex",
] {
let central = deflated_entry(name);
assert!(matches!(
find_core_entry(&central, 1),
Err(CodexDesktopArtifactError::MissingCore)
));
}
}
fn deflated_entry(name: &[u8]) -> Vec<u8> {
let mut central = vec![0_u8; 46 + name.len()];
central[..4].copy_from_slice(b"PK\x01\x02");
central[10..12].copy_from_slice(&8_u16.to_le_bytes());
@@ -66,11 +115,7 @@ fn central_directory_requires_one_deflated_core_entry() {
central[42..46].copy_from_slice(&50_u32.to_le_bytes());
central[46..].copy_from_slice(name);
let entry = find_core_entry(&central, 1).expect("core entry");
assert_eq!(entry.name, name);
assert_eq!(entry.compressed_size, 100);
assert_eq!(entry.uncompressed_size, 200);
assert_eq!(entry.local_header_offset, 50);
central
}
#[test]
+23 -11
View File
@@ -467,19 +467,24 @@ docker compose -f deploy/compose.yaml build codex-proxy-rs
### 版本命名与升级规则
发行通道由当前版本确定,不提供通道切换配置。预发行编号 `N` 从 1 开始递增。
管理端「系统更新」可临时选择通道检查更新,不保存选择,不会自动下载或安装。
每次打开弹窗按当前运行版本匹配通道:正式版为 Stable,预发行按版本后缀匹配。
升级并重启后按新运行版本匹配;侧栏更新提示始终使用当前运行通道。预发行编号 `N` 从 1 开始递增。
| 类型 | 命名示例 | 含义 | 允许在线更新到 |
| --- | --- | --- | --- |
| 正式版 | `3.12.0` | 稳定发布 | 同一大版本内更新的正式版 |
| alpha | `3.12.0-alpha.1` | 下一正式版本的早期开发阶段 | 同一目标版本的更新 alpha、beta、rc、正式版 |
| beta | `3.12.0-beta.1` | 下一正式版本的功能测试阶段 | 同一目标版本的更新 beta、rc、正式版 |
| rc | `3.12.0-rc.1` | 准备转正的发布候选版本 | 同一目标版本的更新 rc、正式版 |
| exp | `3.10.0-exp.1` | 临时功能实验,可能合入正式版或结束维护 | 同一轮实验中编号更高的 exp |
| 通道 | 命名示例 | 接收的版本 |
| --- | --- | --- |
| Stable | `3.12.0` | 正式版 |
| RC | `3.12.0-rc.1` | RC、正式版 |
| Beta | `3.12.0-beta.1` | Beta、RC、正式版 |
| Alpha | `3.12.0-alpha.1` | Alpha、Beta、RC、正式版 |
| Exp | `3.10.0-exp.1` | 同一轮实验中编号更高的 exp,仅实验实例可选 |
“同一目标版本”要求 `X.Y.Z` 相同。alpha → beta → rc → 正式版可以跳过中间阶段,不能倒退;
转正后按正式版规则更新,不自动进入下一轮预发行。同一 `X.Y.Z-exp.N` 系列只用于同一轮实验,
不能混用不同实验分支;变更基线即进入另一实验线,需手动迁移。exp 不自动进入正式版、alpha、beta 或 rc。
普通通道在同一大版本内持续接收更高版本,包括后续小版本和补丁版本的预发行。
切回 Stable 不会降级:例如运行 `3.16.0-beta.2` 时,不能安装 `3.15.1`,需要等待 `3.16.0` 或更高正式版。
下载、安装或待重启期间不能切换通道。
同一 `X.Y.Z-exp.N` 系列只用于同一轮实验,不能混用不同实验分支;变更基线即进入另一实验线,需手动迁移。
exp 不进入正式版、alpha、beta 或 rc,普通实例也不能选择 Exp。
从 exp 切换正式版时需先备份,在目标版本的独立数据库中迁移业务数据,不能直接复用或整库还原实验数据库。
版本大小遵循 [SemVer](https://semver.org/),构建元数据 `+...` 不参与比较。未知预发行标识不提供在线更新。
@@ -494,6 +499,13 @@ alpha、beta、rc、exp 在 GitHub 标记为 Pre-release,不覆盖 GitHub Late
### 管理端在线更新
官方构建按上述规则在线更新;源码等非官方构建返回不支持原因,不提供可用更新。
点击「下载并更新」后在原有更新日志中显示下载、校验与安装过程,完成后显示「待重启」及待生效版本,重启后才使用新程序。
「当前版本」始终表示正在运行的版本,「通道最新」表示所选通道的检查结果。
手动更换镜像或程序后,更新器会核对安装文件并校准历史状态,不以旧成功记录推断需要重启。
运行期间检测到外部文件变动或文件缺失时会报告异常,需要核对完整发行包并重启服务后再检查。
回滚入口只接受与记录指纹一致的完整备份;旧格式记录没有指纹,或备份被替换、丢失时,不再提供在线回滚,
但不会删除备份文件。此时需要按人工部署流程恢复已经核实的发行包。
Compose 提供以下在线更新运行参数:
+12 -4
View File
@@ -1558,15 +1558,20 @@ Key 已删除或未关联时为 `null`,不影响记录返回,不包含密钥
| 方法 | 路由 | 主要 query/body | 说明 |
| --- | --- | --- | --- |
| `GET` | `/api/admin/system/version` | 无 | 当前构建、部署模式和可用更新 |
| `GET` | `/api/admin/system/update/detail` | `refresh=true|false` | 读取或强制刷新 Release 详情 |
| `GET` | `/api/admin/system/update/detail` | `refresh=true|false`、`channel?` | 按临时通道读取或刷新 Release 详情 |
| `GET` | `/api/admin/system/update/events` | 无 | SSE 更新事件流 |
| `POST` | `/api/admin/system/update` | `{ targetVersion }` | 受理后台在线更新,返回 `202` |
| `POST` | `/api/admin/system/update` | `{ targetVersion, channel? }` | 受理后台在线更新,返回 `202` |
| `GET` | `/api/admin/system/update/status` | 无 | 查询当前更新或回滚状态 |
| `POST` | `/api/admin/system/rollback` | 无 | 回滚到保留的上一版本 |
| `POST` | `/api/admin/system/restart` | 无 | 请求进程重启 |
在线更新遵循[版本命名与升级规则](../deploy/README.md#版本命名与升级规则)。版本接口的
`updateChannel` 由当前版本推导,取值为 `stable`、`alpha`、`beta`、`rc`、`exp`,无法识别时为 `unknown`。
详情接口的可选 `channel` 仅作用于本次查询,不保存实例偏好;省略时按当前运行版本推导。
响应的 `policy` 包含本次 `channel` 与 `availableChannels`,普通实例可选 `stable`、`rc`、`beta`、`alpha`,
实验实例仅允许 `exp`。不可用通道返回 `40901`,未知枚举值返回参数错误。
执行时应同时发送页面确认的 `channel` 与 `targetVersion`,服务端冻结该通道并重新复核远端目标;
旧客户端省略通道时按运行版本推导。版本摘要接口始终检查运行通道,不受临时查询影响。
检查与执行使用同一规则,禁止的通道转换、跨实验线、跨大版本、降级或同版本重装均以 `40901` 拒绝。
`hasUpdate=true` 仅表示当前构建支持在线更新,且存在允许的更高版本;`latestVersion`、`releaseUrl` 和
`notes` 对应这个候选。没有可升级候选时,`hasUpdate=false`、`latestVersion` 为当前版本,
@@ -1574,7 +1579,7 @@ Key 已删除或未关联时为 `null`,不影响记录返回,不包含密钥
当前构建不支持在线更新时不查询 Release,`hasUpdate=false`、`latestVersion` 为当前版本,
`releaseUrl` 和 `notes` 为空,不支持原因通过 `updateSupported=false`、`unsupportedReason` 返回。
强制检查失败时通过 `warning` 返回错误,`hasUpdate=false`,不以旧缓存或“没有更新”掩盖失败。
普通查询可复用 20 分钟内的结果。下载时仍会校验目标资产、校验和及归档。
普通查询可复用 20 分钟内的结果,缓存按通道隔离,较慢的旧检查不能覆盖新检查结果。下载时仍会校验目标资产、校验和及归档。
更新 POST 在本地校验目标版本并持久化任务后返回 `202`,数据包含 `operationId`、`targetVersion`、
`deploymentMode` 和 `message`,只表示已受理。Release 查询、远端目标复核、下载、校验及文件替换在后台
@@ -1587,7 +1592,10 @@ Key 已删除或未关联时为 `null`,不影响记录返回,不包含密钥
版本或增加权限。
状态响应的 `currentVersion` 表示已安装文件的版本,运行中的版本仍以 `/version` 为准。
`needRestart=true` 表示成功安装的版本尚未在当前进程生效,此时应调用重启接口,不能重复发起更新。
`needRestart=true` 表示已验证的安装文件尚未在当前进程生效,此时应调用重启接口,不能重复发起更新或切换通道。
最近一次 `operation` 仅表示操作历史,成功记录不等于待重启,也不改变远端 `hasUpdate`。
手动部署后按实际文件校准 `currentVersion`;无法核实的回滚备份不再返回 `previousVersion`。
运行期间的外部文件变动或不完整安装返回错误,不伪装成安装成功。
Host 关闭或任务取消会记录失败终态;状态查询会收敛无执行锁的遗留 `running`。
异常退出留下的锁仍遵循 30 分钟过期规则,未过期前不会抢占其他进程的操作。
实例升级和仓库发版见 [部署文档](../deploy/README.md#镜像升级与源码构建)。
+6 -1
View File
@@ -709,7 +709,12 @@ worker 复用连接测试探针执行真实上游调用,并按冻结代次处
还要通过全部启用插件的静态兼容检查,文件交换前后复核同一插件配置版本,失败或取消时恢复二进制、Web 与
官方插件目录。
Host 在受理时持久化任务并转交后台执行,任务持有操作锁与终态写入责任,不依赖 HTTP 请求的生命周期。
状态文件是更新结果的权威来源,SSE 终态在状态落盘后发送;Host 关闭与任务析构都必须收敛状态。
状态文件记录操作结果,SSE 终态在状态落盘后发送;Host 关闭与任务析构都必须收敛状态。
安装状态由 Host 对照启动时的发行文件指纹、磁盘文件与安装记录校准,历史成功记录本身不构成待重启事实。
二进制、Web 资源、官方插件目录及回滚备份须整体匹配,无法核实的备份不参与回滚。
通道是单次查询或安装的参数,不持久化偏好;默认按运行版本推导,检查和执行共用候选策略,
缓存隔离通道并拒绝过期写回,安装在受理时冻结确认的通道与目标;
Admin/API 只转发策略事实,前端分别展示运行版本、通道候选与待生效版本。
PostgreSQL 备份恢复属于人工维护操作。当前没有部署级维护模式开关,需要先停止应用,
离线处理快照中的非终态任务、计划游标和到期清理条件,再重新验证对象存储并恢复计划。
+11 -1
View File
@@ -1,6 +1,13 @@
import type { RequestOptions } from '../request'
import request from '../request'
export type SystemUpdateChannel = 'stable' | 'rc' | 'beta' | 'alpha' | 'exp'
export interface SystemUpdatePolicy {
channel: SystemUpdateChannel
availableChannels: SystemUpdateChannel[]
}
export interface SystemVersion {
version: string
gitSha: string
@@ -15,6 +22,7 @@ export interface SystemVersion {
}
export interface SystemUpdateDetail {
policy: SystemUpdatePolicy
currentVersion: string
latestVersion: string
hasUpdate: boolean
@@ -68,10 +76,12 @@ export function getSystemVersion(options: RequestOptions = {}) {
interface SystemUpdateDetailQuery {
refresh?: boolean
channel?: SystemUpdateChannel
}
interface SystemUpdateTarget {
targetVersion?: string
targetVersion: string
channel: SystemUpdateChannel
}
export function getSystemUpdateDetail(data: SystemUpdateDetailQuery) {
@@ -1,10 +1,12 @@
<script setup lang="ts">
import type { SystemUpdateDetail } from '@/api'
import { BaseButton, BaseConfirmModal, BaseEmpty, BaseMarkdown, BaseModal, BaseScrollbar, toast } from '@codex-proxy/ui'
import type { SystemUpdateChannel, SystemUpdateDetail } from '@/api'
import { BaseButton, BaseConfirmModal, BaseMarkdown, BaseModal, BasePopover, BaseScrollbar, BaseSegmented, BaseSkeleton, toast } from '@codex-proxy/ui'
import {
ArrowUpCircle,
Circle,
CircleHelp,
Download,
ExternalLink,
Power,
RefreshCw,
@@ -28,18 +30,22 @@ const {
updateInfo,
loading,
checking,
changingChannel,
selectedChannel,
availableChannels,
canChangeChannel,
restartTargetVersion,
updating,
restarting,
updateError,
updateSuccess,
needRestart,
updateLogs,
updateStreaming,
updateStreamError,
hasUpdate,
hasCandidateUpdate: hasUpdate,
canUpdate,
} = storeToRefs(systemUpdateStore)
const { loadSystem, checkUpdates, updateNow, restartNow } = systemUpdateStore
const { loadSystem, checkUpdates, changeChannel, updateNow, restartNow } = systemUpdateStore
const updateLogScrollbar = useTemplateRef<InstanceType<typeof BaseScrollbar>>('updateLogScrollbar')
const updateConfirmOpen = shallowRef(false)
@@ -51,10 +57,11 @@ const presentation = computed(() => resolveSystemUpdatePresentation({
version: version.value,
updateInfo: updateInfo.value,
loading: loading.value,
checking: checking.value || changingChannel.value,
restarting: restarting.value,
updating: updating.value,
updateError: updateError.value,
updateSuccess: updateSuccess.value,
needRestart: needRestart.value,
hasUpdate: hasUpdate.value,
updateStreaming: updateStreaming.value,
updateStreamError: updateStreamError.value,
@@ -70,10 +77,32 @@ const updateLogRows = computed(() =>
})),
)
const channelLabels = {
stable: { label: 'Stable · 正式', description: '仅正式版本' },
rc: { label: 'RC · 候选', description: '包含 RC 和正式版本' },
beta: { label: 'Beta · 测试', description: '包含 Beta、RC 和正式版本' },
alpha: { label: 'Alpha · 早期', description: '包含所有常规预发布和正式版本' },
exp: { label: 'Exp · 实验', description: '仅当前实验线' },
}
const channelOptions = computed(() => availableChannels.value.map(value => ({
value,
label: { stable: 'Stable', rc: 'RC', beta: 'Beta', alpha: 'Alpha', exp: 'Exp' }[value],
})))
async function handleChannelChange(value: string) {
if (!availableChannels.value.includes(value as SystemUpdateChannel))
return
try {
await changeChannel(value as SystemUpdateChannel)
}
catch {}
}
const hasReleaseNotes = computed(() => Boolean(updateInfo.value?.notes?.trim()))
const releaseNotesLoading = computed(() => loading.value || changingChannel.value)
const showUpdateProgress = computed(
() => hasUpdate.value || updating.value || restarting.value || updateLogRows.value.length > 0,
() => updating.value || restarting.value || updateLogRows.value.length > 0,
)
async function scrollUpdateLogsToBottom() {
@@ -111,7 +140,7 @@ async function handleUpdateRequest() {
toast.error(data.warning)
return
}
if (!data?.hasUpdate) {
if (!data?.hasUpdate || !canUpdate.value) {
toast.success('当前没有可用更新')
return
}
@@ -126,7 +155,7 @@ async function handleUpdateRequest() {
updateConfirmOpen.value = true
return
}
await runConfirmedUpdate(remoteTargetVersion)
await runConfirmedUpdate(remoteTargetVersion, data.policy.channel)
}
catch {}
finally {
@@ -134,9 +163,9 @@ async function handleUpdateRequest() {
}
}
async function runConfirmedUpdate(targetVersion: string) {
async function runConfirmedUpdate(targetVersion: string, channel: SystemUpdateChannel) {
try {
const result = await updateNow(targetVersion)
const result = await updateNow(targetVersion, channel)
if (result) {
toast.success('更新已开始')
}
@@ -146,12 +175,13 @@ async function runConfirmedUpdate(targetVersion: string) {
async function handleConfirmUpdate() {
const targetVersion = normalizeSystemVersion(updateConfirmInfo.value?.latestVersion)
if (!targetVersion)
if (!targetVersion || !updateConfirmInfo.value)
return
const channel = updateConfirmInfo.value.policy.channel
updateConfirmOpen.value = false
await nextTick()
await runConfirmedUpdate(targetVersion)
await runConfirmedUpdate(targetVersion, channel)
}
async function handleRestart() {
@@ -233,6 +263,7 @@ watch(
:href="item.releaseUrl"
target="_blank"
rel="noreferrer"
:class="{ invisible: checking || changingChannel }"
class="inline-flex shrink-0 items-center gap-1 text-cp-xs leading-none font-bold text-cp-link transition-colors hover:text-cp-link-hover"
>
发布页
@@ -240,14 +271,27 @@ watch(
</a>
</div>
<p
class="mt-2 mb-0 truncate font-mono text-cp leading-none font-bold text-cp-text"
class="mt-2 mb-0 h-[1em] truncate font-mono text-cp leading-none font-bold text-cp-text"
:title="item.title || item.value"
>
{{ item.value }}
<BaseSkeleton
v-if="loading || ((checking || changingChannel) && item.key === 'latest')"
class="h-full w-20 max-w-full"
aria-label="正在检查版本"
/>
<template v-else>
{{ item.value }}
</template>
</p>
</div>
</div>
<p v-if="needRestart" class="m-0 text-cp-sm font-emphasis text-cp-success">
v{{ restartTargetVersion }} 已安装,重启后生效
</p>
<p v-if="updateInfo?.unsupportedReason" class="m-0 text-cp-sm text-cp-text-secondary">
{{ updateInfo.unsupportedReason }}
</p>
<p
v-if="updateError || updateInfo?.warning"
class="m-0 rounded-cp bg-cp-error-container px-3 py-2 text-cp-sm leading-normal font-bold text-cp-error-on-container"
@@ -257,7 +301,7 @@ watch(
</section>
<section
v-if="hasReleaseNotes"
v-if="hasReleaseNotes || releaseNotesLoading"
class="grid gap-2 rounded-cp-card bg-cp-fill-quaternary px-4 py-3.5"
>
<div class="flex items-center justify-between gap-3">
@@ -265,12 +309,24 @@ watch(
发布说明
</p>
<span class="font-mono text-cp-xs font-emphasis text-cp-text-quaternary">
{{ presentation.releaseVersion }}
<BaseSkeleton v-if="releaseNotesLoading" shape="text" class="w-14" aria-hidden="true" />
<template v-else>{{ presentation.releaseVersion }}</template>
</span>
</div>
<BaseScrollbar class="-mx-4" max-height="160px">
<div class="px-4">
<BaseMarkdown :source="updateInfo?.notes" />
<div
class="relative px-4"
:class="{ 'min-h-40': releaseNotesLoading && !hasReleaseNotes }"
:aria-busy="releaseNotesLoading"
>
<div :class="{ invisible: releaseNotesLoading }" :aria-hidden="releaseNotesLoading || undefined">
<BaseMarkdown :source="updateInfo?.notes" />
</div>
<div v-if="releaseNotesLoading" class="absolute inset-x-4 top-0 grid h-full content-start gap-3 overflow-hidden py-1" aria-hidden="true">
<BaseSkeleton shape="text" class="w-20" />
<BaseSkeleton shape="text" class="w-4/5" />
<BaseSkeleton shape="text" class="w-3/5" />
</div>
</div>
</BaseScrollbar>
</section>
@@ -322,17 +378,42 @@ watch(
</div>
</div>
</BaseScrollbar>
<div v-else class="grid h-30 place-items-center px-4 pb-4">
<BaseEmpty title="暂无进度" :icon="Terminal" size="sm" surface="none" />
</div>
</section>
</div>
<template #footer>
<div class="mr-auto flex min-w-0 basis-full items-center gap-2 sm:basis-auto">
<BaseSkeleton v-if="loading" class="h-8 w-52" aria-label="正在读取运行通道" />
<BaseSegmented
v-else
:model-value="selectedChannel"
:options="channelOptions"
label="更新通道"
size="sm"
:disabled="!canChangeChannel || preparingUpdate || updateConfirmOpen"
@update:model-value="handleChannelChange"
/>
<BasePopover placement="top-start">
<template #trigger>
<BaseButton variant="ghost" size="sm" square aria-label="更新通道说明">
<CircleHelp class="size-4" />
</BaseButton>
</template>
<div class="grid w-72 gap-2 p-3 text-cp-sm text-cp-text-secondary">
<p v-for="channel in availableChannels" :key="channel" class="m-0">
<strong class="font-heavy text-cp-text">{{ channelLabels[channel].label }}</strong>
· {{ channelLabels[channel].description }}
</p>
<p class="m-0 text-cp-text-quaternary">
选择仅用于本次检查,重新打开时按运行版本选择通道,不会自动安装或降级
</p>
</div>
</BasePopover>
</div>
<BaseButton
variant="secondary"
:loading="checking"
:disabled="loading || updating || restarting"
:disabled="loading || updating || restarting || changingChannel || preparingUpdate"
@click="handleCheckUpdates(true)"
>
<template #loading>
@@ -344,7 +425,7 @@ watch(
检查更新
</BaseButton>
<BaseButton
v-if="updateSuccess && needRestart"
v-if="needRestart"
variant="primary"
:loading="restarting"
:disabled="updating"
@@ -363,9 +444,9 @@ watch(
@click="handleUpdateRequest"
>
<template #icon>
<ArrowUpCircle class="size-4" />
<Download class="size-4" />
</template>
立即更新
{{ updating ? '更新中' : '下载并更新' }}
</BaseButton>
</template>
</BaseModal>
@@ -373,7 +454,7 @@ watch(
<BaseConfirmModal
v-model="updateConfirmOpen"
title="发现新的更新版本"
description="检测到远端 latest 与当前显示的目标版本不一致"
description="所选通道的最新版本已变化"
confirm-text="确认更新"
:loading="updating"
:confirm-disabled="!updateConfirmInfo?.latestVersion"
@@ -5,10 +5,11 @@ interface SystemUpdatePresentationInput {
version: SystemVersion | null
updateInfo: SystemUpdateDetail | null
loading: boolean
checking: boolean
restarting: boolean
updating: boolean
updateError: string
updateSuccess: boolean
needRestart: boolean
hasUpdate: boolean
updateStreaming: boolean
updateStreamError: string
@@ -43,9 +44,9 @@ export function resolveSystemUpdateLogClasses(level: string) {
}
function resolveStatus(input: SystemUpdatePresentationInput) {
if (input.restarting || input.updating) {
if (input.restarting || input.updating || input.checking || input.loading) {
return {
label: input.restarting ? '重启中' : '更新中',
label: input.restarting ? '重启中' : input.updating ? '更新中' : '检查中',
icon: RefreshCw,
badge: 'bg-cp-info-container text-cp-info-on-container',
iconClass: 'text-cp-info',
@@ -59,9 +60,9 @@ function resolveStatus(input: SystemUpdatePresentationInput) {
iconClass: 'text-cp-error',
}
}
if (input.updateSuccess || input.hasUpdate || input.updateInfo) {
if (input.needRestart || input.hasUpdate || input.updateInfo) {
return {
label: input.updateSuccess ? '已更新' : input.hasUpdate ? '有可用更新' : '无可用更新',
label: input.needRestart ? '待重启' : input.hasUpdate ? '有可用更新' : '无可用更新',
icon: input.hasUpdate ? ArrowUpCircle : CheckCircle2,
badge: 'bg-cp-success-container text-cp-success-on-container',
iconClass: 'text-cp-success',
@@ -80,12 +81,12 @@ function resolveSummaryItems(input: SystemUpdatePresentationInput) {
{
key: 'current',
label: '当前版本',
value: input.loading ? '...' : versionLabel(input.version?.version),
title: input.version?.version,
value: input.loading ? '...' : versionLabel(input.updateInfo?.currentVersion ?? input.version?.version),
title: input.updateInfo?.currentVersion ?? input.version?.version,
},
{
key: 'latest',
label: '最新版本',
label: '通道最新',
value: versionLabel(input.updateInfo?.latestVersion),
title: input.updateInfo?.latestVersion,
releaseUrl: input.updateInfo?.releaseUrl,
+100 -42
View File
@@ -1,4 +1,4 @@
import type { SystemUpdateStatus } from '@/api'
import type { SystemUpdateChannel, SystemUpdatePolicy, SystemUpdateStatus } from '@/api'
import { until, useEventSource, useTimeoutPoll } from '@vueuse/core'
import { delay } from 'es-toolkit'
import { defineStore } from 'pinia'
@@ -38,6 +38,7 @@ type SystemUpdatePhase
= | { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'checking' }
| { kind: 'changing_channel' }
| { kind: 'ready' }
| { kind: 'updating' }
| { kind: 'restart_required' }
@@ -47,6 +48,7 @@ type SystemUpdatePhase
const UPDATE_BUSY_PHASES = new Set<SystemUpdatePhase['kind']>([
'loading',
'checking',
'changing_channel',
'updating',
'restarting',
])
@@ -56,13 +58,12 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
const updateInfo = shallowRef<Awaited<ReturnType<typeof getSystemUpdateDetail>> | null>(null)
const phase = shallowRef<SystemUpdatePhase>({ kind: 'idle' })
const updateError = shallowRef('')
const updateSuccess = shallowRef(false)
const needRestart = shallowRef(false)
const statusAvailable = shallowRef(false)
const policy = shallowRef<SystemUpdatePolicy | null>(null)
const loadedOnce = shallowRef(false)
const updateLogs = ref<SystemUpdateEvent[]>([])
const updateStreaming = shallowRef(false)
const updateStreamError = shallowRef('')
const restartTargetVersion = shallowRef('')
const updateStatus = shallowRef<SystemUpdateStatus | null>(null)
const {
data: updateEventMessage,
@@ -84,6 +85,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
let loadSystemPromise: Promise<void> | undefined
let statusRequest: Promise<void> | undefined
let statusGeneration = 0
let detailGeneration = 0
let submitting = false
let activeOperationId: string | null = null
let unconfirmedPreviousId: string | null | undefined
@@ -92,13 +94,24 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
const phaseKind = computed(() => phase.value.kind)
const loading = computed(() => phaseKind.value === 'loading')
const checking = computed(() => phaseKind.value === 'checking')
const changingChannel = computed(() => phaseKind.value === 'changing_channel')
const updating = computed(() => phaseKind.value === 'updating')
const restarting = computed(() => phaseKind.value === 'restarting')
const hasUpdate = computed(() => Boolean(updateInfo.value?.hasUpdate ?? version.value?.hasUpdate))
const needRestart = computed(() => Boolean(updateStatus.value?.needRestart))
const restartTargetVersion = computed(() => needRestart.value ? normalizeSystemVersion(updateStatus.value?.currentVersion) : '')
const selectedChannel = computed(() => policy.value?.channel ?? 'stable')
const availableChannels = computed(() => policy.value?.availableChannels ?? [])
const canChangeChannel = computed(() => availableChannels.value.length > 1
&& Boolean(updateInfo.value) && !updateInfo.value?.unsupportedReason
&& statusAvailable.value && !needRestart.value && !UPDATE_BUSY_PHASES.has(phaseKind.value))
const hasUpdate = computed(() => Boolean(version.value?.hasUpdate))
const hasCandidateUpdate = computed(() => Boolean(updateInfo.value?.hasUpdate))
const canUpdate = computed(
() =>
hasUpdate.value
hasCandidateUpdate.value
&& statusAvailable.value
&& updateInfo.value?.policy.channel === selectedChannel.value
&& Boolean(updateInfo.value?.updateSupported)
&& !needRestart.value
&& !UPDATE_BUSY_PHASES.has(phaseKind.value),
@@ -110,9 +123,6 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
function resetUpdateResult() {
updateError.value = ''
updateSuccess.value = false
needRestart.value = false
restartTargetVersion.value = ''
}
function appendUpdateLog(log: SystemUpdateEvent) {
@@ -184,6 +194,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
function applyUpdateStatus(status: SystemUpdateStatus) {
const operation = status.operation
statusAvailable.value = true
updateStatus.value = status
if (unconfirmedPreviousId !== undefined && operation.operationId === unconfirmedPreviousId) {
unconfirmedPreviousId = undefined
@@ -196,9 +207,6 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
unconfirmedPreviousId = undefined
activeOperationId = operation.operationId
updateError.value = ''
updateSuccess.value = operation.status === 'succeeded' && status.needRestart
needRestart.value = status.needRestart
restartTargetVersion.value = status.needRestart ? normalizeSystemVersion(status.currentVersion) : ''
if (operation.status === 'running') {
setPhase({ kind: 'updating' })
if (!statusPoll.isActive.value)
@@ -210,13 +218,9 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
disconnectUpdateEvents()
if (operation.status === 'failed') {
updateError.value = operation.error || operation.message || '更新失败'
setPhase({ kind: 'failed' })
return
}
if (status.needRestart && updateInfo.value) {
updateInfo.value = { ...updateInfo.value, hasUpdate: false }
}
setPhase(status.needRestart ? { kind: 'restart_required' } : { kind: 'ready' })
if (!['loading', 'checking', 'changing_channel'].includes(phaseKind.value))
settlePhase()
}
async function refreshUpdateStatus() {
@@ -236,7 +240,9 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
catch (error: unknown) {
if (generation !== statusGeneration)
return
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
statusAvailable.value = false
const nonRetryable = error instanceof ApiError && error.status >= 400 && error.status < 500 && error.status !== 408 && error.status !== 429
if (nonRetryable || (!updating.value && unconfirmedPreviousId === undefined)) {
statusPoll.pause()
disconnectUpdateEvents()
updateError.value = errorMessage(error)
@@ -256,25 +262,40 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
return statusRequest
}
function settlePhase() {
setPhase(needRestart.value ? { kind: 'restart_required' } : updateError.value ? { kind: 'failed' } : { kind: 'ready' })
}
async function loadDetail(refresh: boolean, generation: number, channel?: SystemUpdateChannel) {
const detail = await getSystemUpdateDetail({ refresh, channel })
if (generation !== detailGeneration)
return
updateInfo.value = detail
policy.value = detail.policy
loadedOnce.value = true
if (!version.value)
await loadVersion()
if (generation !== detailGeneration)
return
if (version.value && detail.policy.channel === version.value.updateChannel) {
// 侧栏只提示当前运行通道的更新,临时查看其他通道不会改变全局提示。
version.value = { ...version.value, latestVersion: detail.latestVersion, hasUpdate: detail.hasUpdate, updateCached: detail.cached, updateWarning: detail.warning }
}
}
async function loadSystem(refresh = false) {
if (loadSystemPromise)
return loadSystemPromise
if (updating.value || restarting.value)
return
if (!UPDATE_BUSY_PHASES.has(phaseKind.value)) {
setPhase({ kind: 'loading' })
}
const generation = ++detailGeneration
setPhase({ kind: 'loading' })
loadSystemPromise = (async () => {
await Promise.all([
refreshUpdateStatus(),
(async () => {
updateInfo.value = await getSystemUpdateDetail({ refresh })
if (!version.value)
version.value = await getSystemVersion()
loadedOnce.value = true
})(),
loadDetail(refresh, generation),
])
if (needRestart.value && updateInfo.value)
updateInfo.value = { ...updateInfo.value, hasUpdate: false }
})()
try {
@@ -282,7 +303,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
}
finally {
if (phaseKind.value === 'loading')
setPhase({ kind: 'ready' })
settlePhase()
loadSystemPromise = undefined
}
}
@@ -303,29 +324,60 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
}
async function checkUpdates(refresh = true) {
if (checking.value || updating.value || restarting.value)
if (UPDATE_BUSY_PHASES.has(phaseKind.value))
return updateInfo.value
const generation = ++detailGeneration
setPhase({ kind: 'checking' })
resetUpdateResult()
try {
updateInfo.value = await getSystemUpdateDetail({ refresh })
if (!version.value)
version.value = await getSystemVersion()
loadedOnce.value = true
await loadDetail(refresh, generation, selectedChannel.value)
if (generation !== detailGeneration)
return
await refreshUpdateStatus()
return updateInfo.value
}
finally {
if (phaseKind.value === 'checking')
setPhase({ kind: 'ready' })
if (generation === detailGeneration && phaseKind.value === 'checking')
settlePhase()
}
}
async function updateNow(targetVersion: string) {
async function changeChannel(channel: SystemUpdateChannel) {
if (!canChangeChannel.value || channel === selectedChannel.value)
return
const generation = ++detailGeneration
setPhase({ kind: 'changing_channel' })
resetUpdateResult()
try {
if (policy.value)
policy.value = { ...policy.value, channel }
// 选择只影响本次检查,重新打开时恢复运行通道;旧候选不能用于新通道下载。
await loadDetail(true, generation, channel)
if (generation !== detailGeneration)
return
await refreshUpdateStatus()
}
catch (error: unknown) {
if (generation !== detailGeneration)
return
if (updateInfo.value?.policy.channel !== selectedChannel.value)
updateInfo.value = null
updateError.value = errorMessage(error)
throw error
}
finally {
if (generation === detailGeneration && phaseKind.value === 'changing_channel')
settlePhase()
}
}
async function updateNow(targetVersion: string, channel: SystemUpdateChannel) {
const confirmedTargetVersion = normalizeSystemVersion(targetVersion)
if (!canUpdate.value || !confirmedTargetVersion)
if (!canUpdate.value || !confirmedTargetVersion || channel !== selectedChannel.value
|| confirmedTargetVersion !== normalizeSystemVersion(updateInfo.value?.latestVersion)) {
return null
}
statusGeneration += 1
submitting = true
@@ -338,7 +390,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
setPhase({ kind: 'updating' })
try {
await connectUpdateEvents(true)
const result = await performSystemUpdate({ targetVersion: confirmedTargetVersion }, { silent: true })
const result = await performSystemUpdate({ targetVersion: confirmedTargetVersion, channel }, { silent: true })
activeOperationId = result.operationId
updateLogs.value = updateLogs.value.filter(event => event.operationId === result.operationId)
return result
@@ -429,20 +481,26 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
phase,
loading,
checking,
changingChannel,
selectedChannel,
availableChannels,
canChangeChannel,
restartTargetVersion,
updating,
restarting,
updateError,
updateSuccess,
needRestart,
loadedOnce,
updateLogs,
updateStreaming,
updateStreamError,
hasUpdate,
hasCandidateUpdate,
canUpdate,
loadVersion,
loadSystem,
checkUpdates,
changeChannel,
updateNow,
restartNow,
connectUpdateEvents,
@@ -248,8 +248,8 @@ watch(
</div>
</div>
<dl class="m-0 grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-x-7 gap-y-5">
<div v-if="profile.provider === 'xai'" class="min-w-0">
<dl class="m-0 flex min-w-0 flex-wrap gap-x-7 gap-y-5">
<div v-if="profile.provider === 'xai'" class="min-w-0 flex-auto">
<dt class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-text-quaternary">
<ShieldCheck class="size-3.25 text-cp-text-tertiary" />
认证协议
@@ -262,7 +262,7 @@ watch(
</dd>
</div>
<div class="min-w-0">
<div class="min-w-0 flex-auto">
<dt class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-text-quaternary">
<Monitor class="size-3.25 text-cp-text-tertiary" />
{{ profile.provider === 'openai' ? '模拟运行环境' : '运行环境' }}
@@ -278,7 +278,7 @@ watch(
</dd>
</div>
<div v-for="(attribute, index) in identityAttributes" :key="index" class="min-w-0">
<div v-for="(attribute, index) in identityAttributes" :key="index" class="min-w-0 max-w-full">
<dt class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-text-quaternary">
<Terminal class="size-3.25 text-cp-text-tertiary" />
{{ attribute.label }}
+9 -21
View File
@@ -1,29 +1,17 @@
# v3.15.0
## 新增功能
- 扩展 OpenAI 客户端身份配置:支持 CLI 的 Core、TUI、Exec 入口和完整自定义用户代理,自动识别官方身份对应的请求头;官方预设继续通过每日检查跟随官方版本,可用于全局设置和密钥独立配置
- 插件支持发布模型别名,模型列表与请求路由共用目录,并沿用目标模型的能力和访问范围
- 新增插件只读 `data` 访问域,可查询账号基础信息和已有额度观测,展示类插件无需申请凭据访问权限
- 插件可在宿主允许的范围内参与重试决策;中间件 v2 支持声明由插件承担的功能转换,宿主继续校验实际请求的路由需求
# v3.15.2
## 问题修复
- 修复过期代理测试结果返回冲突后,事务锁尚未释放可能导致紧接着的代理编辑也报冲突的问题
- 修复客户端身份切换后恢复原配置,系统设置仍被误判为未保存的问题
- 保留中间件未修改的请求正文、请求头与响应流内容,避免仅解析或观察数据改变透传结果
- 修复手动更换 Docker 镜像或程序后,历史更新记录导致误显示“已更新/立即重启”、隐藏下载入口的问题
- 核对实际安装文件与回滚备份,正确区分运行版本、已安装版本和待重启状态,避免失效备份参与在线回滚
## 体验优化
## 功能与体验
- 客户端身份沿用紧凑的预设与版本策略布局,选择自定义版本时自动带入当前用户代理;精简重复说明、调整加载状态,并统一相关工具栏按钮样式
- 插件配置的资源选择复用多选控件,简化模型、账号等资源的选择与回显
## 开发与协作
- 增加组件库与插件示例的源码联调入口,支持本地 UI 热更新、源码构建和 SDK 联调检查,正式构建继续使用锁定依赖
- 仓库技能统一使用 `cpr-` 前缀,明确开发与 PR 流程、后端自审、页面截图及 AI 模型披露要求
- 系统更新支持临时选择 Stable、RC、Beta、Alpha 通道检查更新;选择不会保存或自动安装,重新打开时按当前运行版本匹配通道,不允许降级
- 通道选择使用紧凑分段切换,首次加载和切换时显示关键信息骨架屏,减少弹窗跳动;下载与安装过程沿用更新日志展示
## 升级说明
- 从 v3.14.1 升级未新增或修改数据库迁移,继续使用现有配置与数据
- 使用新增插件能力时需更新公开 SDK,并部署支持对应能力版本的宿主;现有中间件 v1 仍受支持
- 从 v3.15.1 升级未新增或修改数据库迁移,无需调整配置
- 若旧版本已误显示“立即重启”且没有下载入口,请通过 Docker 拉取新镜像或手动替换完整发行包升级;新版本启动后会校准历史更新状态
- 旧格式更新记录无法验证备份完整性,升级后不再提供对应的在线回滚;备份文件仍保留,需要恢复时请使用已核实的完整发行包
+1 -1
View File
@@ -1 +1 @@
version: "3.15.0"
version: "3.15.2"