feat(system): add update channels and reconcile installation state

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-26 04:41:53 +08:00
co-authored by Codex
parent bb76399e59
commit 2956f07723
27 changed files with 1304 additions and 251 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())
+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,