Merge remote-tracking branch 'origin/master' into feat/fail-as-event
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m39s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m53s
Project CI / Backend tests (pull_request) Successful in 5m10s
Project CI / Native shell tests (pull_request) Successful in 6m36s
Project CI / Frontend tests (pull_request) Successful in 2m6s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m8s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m2s
Project CI / Repository checks (pull_request) Successful in 1m53s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m24s

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-09-24 19:17:48 +08:00
30 changed files with 1745 additions and 117 deletions
+1
View File
@@ -65,6 +65,7 @@ temp*build*/
/apps/preview-deployer-web/node_modules/
/server-rs/.spacetimedb/
/server-rs/.data/
**/server-rs/.data/
/public/generated-animations
/public/generated-character-drafts
/public/generated-characters
@@ -6,7 +6,7 @@
"visibleModels": [],
"apiKey": "",
"baseUrl": "https://dev.genarrative.world/gpt/v1",
"model": "gpt-6-astra",
"model": "platform-default",
"apiKind": "openai_responses",
"reasoningEffort": "max",
"stream": true,
@@ -1497,6 +1497,15 @@ if (defaultAppConfig.llm?.apiKey !== '') {
throw new Error('AI game creator shell default llm.apiKey must stay empty');
}
// 首次启动模板必须写入官方路由占位模型(与 config.rs 的
// OFFICIAL_LLM_ROUTER_DEFAULT_MODEL 同源):钉死具体上游模型名会随上游目录
// 变动失效,留空则首启配置不合法。
if (defaultAppConfig.llm?.model !== 'platform-default') {
throw new Error(
'AI game creator shell default llm.model must stay the official route placeholder',
);
}
if (defaultAppConfig.agentMode !== 'codex_app_server') {
throw new Error(
'AI game creator shell default agentMode must be codex_app_server',
@@ -0,0 +1,269 @@
//! ACL 提权修复目标的并发去重与结果记忆。
//!
//! 同一目标被并发请求时只允许一次真实提权,其余调用等待并复用同一结果;
//! 结果在冷却窗口内直接复用,其中用户拒绝(UAC 取消)的窗口最长,
//! 避免自动重试把用户反复拽回安全桌面。
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Condvar, LazyLock, Mutex};
use std::time::{Duration, Instant};
/// 一次提权修复的结果。用户拒绝与修复失败必须可区分:前者不该被重试。
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum AclRepairOutcome {
Repaired,
Denied(String),
Failed(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum AclRepairGateResult {
Executed(AclRepairOutcome),
Reused(AclRepairOutcome),
/// leader 在等待窗口内仍未结束(例如 UAC 无人应答);调用方按失败关闭处理。
WaitTimedOut,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct AclRepairPolicy {
pub(crate) success_cooldown: Duration,
pub(crate) denial_cooldown: Duration,
pub(crate) failure_cooldown: Duration,
pub(crate) wait_timeout: Duration,
/// leader 超过这个时长仍未落库即视为卡死,允许新调用接管该 key。
/// UAC 弹窗最多被系统挂约两分钟,所以这个上限取得比它宽得多;没有它,
/// 一次挂死的 `Start-Process -Wait` 会让这个目标在进程重启前一直失败关闭。
pub(crate) leader_deadline: Duration,
}
impl AclRepairPolicy {
fn cooldown_for(&self, outcome: &AclRepairOutcome) -> Duration {
match outcome {
AclRepairOutcome::Repaired => self.success_cooldown,
AclRepairOutcome::Denied(_) => self.denial_cooldown,
AclRepairOutcome::Failed(_) => self.failure_cooldown,
}
}
fn retention(&self) -> Duration {
self.success_cooldown
.max(self.denial_cooldown)
.max(self.failure_cooldown)
}
}
struct Entry {
running: bool,
outcome: Option<AclRepairOutcome>,
recorded_at: Option<Instant>,
/// leader 起跑时刻,用于判定该 leader 是否已经卡死。
started_at: Instant,
/// 当前 leader 的令牌:被接管后旧 leader 迟到的结果不得覆盖新 leader 的结果。
leader_id: u64,
}
pub(crate) struct AclRepairGate<K> {
entries: Mutex<HashMap<K, Entry>>,
settled: Condvar,
next_leader_id: AtomicU64,
}
impl<K: Clone + Eq + Hash> AclRepairGate<K> {
pub(crate) fn new() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
settled: Condvar::new(),
next_leader_id: AtomicU64::new(1),
}
}
/// 以 `key` 为粒度执行一次提权修复:并发调用只会有一次真正执行,
/// 其余调用等待并复用结果;冷却窗口内直接复用上一次结果。
pub(crate) fn run<F>(
&self,
key: K,
now: Instant,
policy: &AclRepairPolicy,
execute: F,
) -> AclRepairGateResult
where
F: FnOnce() -> AclRepairOutcome,
{
let wait_deadline = Instant::now() + policy.wait_timeout;
let mut entries = lock(&self.entries);
loop {
match entries.get(&key) {
Some(entry) if entry.running => {
// 卡死的 leader(例如 `Start-Process -Wait` 真挂住)不能永久占住这个 key:
// 超过 leader_deadline 就由新调用接管,否则该目标在进程重启前只会一直失败关闭。
if now.saturating_duration_since(entry.started_at) >= policy.leader_deadline {
break;
}
let remaining = wait_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return AclRepairGateResult::WaitTimedOut;
}
let (guard, _) = self
.settled
.wait_timeout(entries, remaining)
.unwrap_or_else(|poisoned| poisoned.into_inner());
entries = guard;
}
Some(entry) => {
let reusable = entry.outcome.clone().zip(entry.recorded_at).filter(
|(outcome, recorded_at)| {
now.saturating_duration_since(*recorded_at)
< policy.cooldown_for(outcome)
},
);
match reusable {
Some((outcome, _)) => return AclRepairGateResult::Reused(outcome),
None => break,
}
}
None => break,
}
}
prune(&mut entries, now, policy);
let leader_id = self.next_leader_id.fetch_add(1, Ordering::Relaxed);
entries.insert(
key.clone(),
Entry {
running: true,
outcome: None,
// 结果尚未落库:冷却基准只在真正记录结果时才写。
recorded_at: None,
started_at: now,
leader_id,
},
);
drop(entries);
let guard = LeaderGuard {
gate: self,
key: key.clone(),
leader_id,
armed: true,
};
let outcome = execute();
guard.complete(outcome)
}
/// 用户主动操作后允许重新尝试提权:清掉「被拒绝」的记忆。
pub(crate) fn clear_denials(&self) {
let mut entries = lock(&self.entries);
entries.retain(|_, entry| {
entry.running || !matches!(entry.outcome, Some(AclRepairOutcome::Denied(_)))
});
drop(entries);
self.settled.notify_all();
}
#[cfg(test)]
pub(crate) fn is_running(&self, key: &K) -> bool {
lock(&self.entries)
.get(key)
.is_some_and(|entry| entry.running)
}
}
impl<K> Default for AclRepairGate<K>
where
K: Clone + Eq + Hash,
{
fn default() -> Self {
Self::new()
}
}
struct LeaderGuard<'a, K: Clone + Eq + Hash> {
gate: &'a AclRepairGate<K>,
key: K,
leader_id: u64,
armed: bool,
}
impl<K: Clone + Eq + Hash> LeaderGuard<'_, K> {
fn complete(mut self, outcome: AclRepairOutcome) -> AclRepairGateResult {
self.armed = false;
let mut entries = lock(&self.gate.entries);
// 只在仍是当前 leader 时落库:leader 卡死被接管后,迟到的结果必须丢弃,
// 否则会把接管者已经写下的结果覆盖回去。
if let Some(entry) = entries.get_mut(&self.key) {
if entry.leader_id == self.leader_id {
entry.running = false;
entry.outcome = Some(outcome.clone());
// 冷却从「结果落库」时刻算起,而不是 leader 起跑时刻:UAC 弹窗可能被挂着
// 几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,
// 紧接着的自动重查会立刻再弹一次。
entry.recorded_at = Some(Instant::now());
}
}
drop(entries);
self.gate.settled.notify_all();
AclRepairGateResult::Executed(outcome)
}
}
impl<K: Clone + Eq + Hash> Drop for LeaderGuard<'_, K> {
/// leader 异常退出时不能让等待者永久挂住:记成失败并唤醒全部等待者。
fn drop(&mut self) {
if !self.armed {
return;
}
let mut entries = lock(&self.gate.entries);
if let Some(entry) = entries.get_mut(&self.key) {
if entry.leader_id == self.leader_id {
entry.running = false;
entry.outcome = Some(AclRepairOutcome::Failed(
"AGC ACL 提权修复执行线程异常退出".to_string(),
));
entry.recorded_at = Some(Instant::now());
}
}
drop(entries);
self.gate.settled.notify_all();
}
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn prune<K: Eq + Hash>(entries: &mut HashMap<K, Entry>, now: Instant, policy: &AclRepairPolicy) {
// 只是防止 map 随进程生命周期无限增长;窗口远大于冷却期即可。
let retention = policy.retention().saturating_mul(4);
entries.retain(|_, entry| {
if entry.running {
return true;
}
entry
.recorded_at
.is_none_or(|recorded_at| now.saturating_duration_since(recorded_at) < retention)
});
}
/// 提权修复的进程级闸门;key = (规范化目标路径, scope 名)。
pub(crate) type AclRepairKey = (String, &'static str);
pub(crate) static ACL_REPAIR_GATE: LazyLock<AclRepairGate<AclRepairKey>> =
LazyLock::new(AclRepairGate::new);
pub(crate) const ACL_REPAIR_POLICY: AclRepairPolicy = AclRepairPolicy {
success_cooldown: Duration::from_secs(30),
denial_cooldown: Duration::from_secs(120),
failure_cooldown: Duration::from_secs(15),
wait_timeout: Duration::from_secs(60),
// 系统对无人应答的 UAC 弹窗约 2 分钟超时,取 5 分钟只兜「真挂死」这一种情况。
leader_deadline: Duration::from_secs(300),
};
/// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除「被拒绝」记忆。
pub(crate) fn clear_acl_repair_denials() {
ACL_REPAIR_GATE.clear_denials();
}
@@ -1874,6 +1874,12 @@ pub(crate) fn read_game_creator_app_config() -> Result<GameCreatorAppConfigView,
game_creator_app_config_view(load_game_creator_app_config()?)
}
/// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除 ACL 提权拒绝记忆。
#[tauri::command]
pub(crate) fn clear_game_creator_acl_elevation_denials() {
crate::config::clear_windows_acl_repair_denials();
}
static GAME_CREATOR_CONFIG_WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[tauri::command]
@@ -2779,15 +2779,50 @@ fn windows_acl_repair_argument_list(
.join(" ")
}
/// 用户取消 UAC 的稳定错误标记:调用方(前端)据此判定「不可自动重试」,
/// 而不是去匹配中文文案。
#[cfg(windows)]
pub(crate) const WINDOWS_ACL_REPAIR_DENIED_MARKER: &str = "AGC_ACL_ELEVATION_DENIED";
/// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除提权拒绝记忆,
/// 使同一次会话内的显式重试仍能再次请求提权。
pub(crate) fn clear_windows_acl_repair_denials() {
crate::acl_repair_gate::clear_acl_repair_denials();
}
/// 闸门 key 的路径半边:`\\?\` 扩展长度前缀与 `\\?\UNC\` 必须先归一化,
/// 否则同一个物理目录的不同写法会算出不同 key,single-flight 就退化成「每种写法弹一次」。
/// 最近项目列表里同一项目会同时存在 `\\?\C:\...` 与 `C:\...` 两种形态,归一化后它们共用一次提权。
/// 这里只做前缀与大小写归一(不 `canonicalize`):待修复目标恰恰是「读不动的目录」,解析不可靠。
#[cfg(windows)]
pub(crate) fn windows_acl_repair_gate_key(
repair_path: &Path,
scope: WindowsAclRepairScope,
) -> crate::acl_repair_gate::AclRepairKey {
(
normalize_windows_policy_path(repair_path)
.to_string_lossy()
.to_lowercase(),
scope.wire_name(),
)
}
/// Starts a one-shot elevated copy of the current executable. The elevated
/// process performs only the allow-listed ACL repair command and exits with a
/// truthful status; UAC cancellation is never treated as success.
///
/// 同一 (规范化目标, scope) 的修复在进程内做 single-flight:并发调用只会有一次
/// 真实提权,其余等待并复用结果;冷却窗口内直接复用,避免自动重试反复弹 UAC。
#[cfg(windows)]
fn attempt_elevated_windows_acl_repair(
path: &Path,
target_user_sid: &str,
scope: WindowsAclRepairScope,
) -> Result<(), String> {
use crate::acl_repair_gate::{
AclRepairGateResult, AclRepairOutcome, ACL_REPAIR_GATE, ACL_REPAIR_POLICY,
};
if !scope.allows_path(path) {
return Err(format!(
"AGC ACL 提权目标不在当前用户允许的 {} 范围内:{}",
@@ -2795,13 +2830,53 @@ fn attempt_elevated_windows_acl_repair(
path.display()
));
}
let executable =
std::env::current_exe().map_err(|error| format!("定位 AGC ACL 修复程序失败:{error}"))?;
if !executable.is_file() {
return Err("AGC ACL 修复程序不存在".to_string());
}
let repair_path = windows_acl_repair_target(path, scope);
let nonce = create_windows_acl_repair_authorization(&repair_path, target_user_sid, scope)?;
let key = windows_acl_repair_gate_key(&repair_path, scope);
let gate_result =
ACL_REPAIR_GATE.run(key, std::time::Instant::now(), &ACL_REPAIR_POLICY, || {
run_elevated_windows_acl_repair_once(path, target_user_sid, scope, &repair_path)
});
match gate_result {
AclRepairGateResult::Executed(outcome) | AclRepairGateResult::Reused(outcome) => {
match outcome {
AclRepairOutcome::Repaired => Ok(()),
AclRepairOutcome::Denied(detail) => {
Err(format!("{WINDOWS_ACL_REPAIR_DENIED_MARKER}{detail}"))
}
AclRepairOutcome::Failed(detail) => Err(detail),
}
}
AclRepairGateResult::WaitTimedOut => Err(format!(
"AGC ACL 提权修复等待超时:同一目标的提权仍在进行中:{}",
repair_path.display()
)),
}
}
#[cfg(windows)]
fn run_elevated_windows_acl_repair_once(
path: &Path,
target_user_sid: &str,
scope: WindowsAclRepairScope,
repair_path: &Path,
) -> crate::acl_repair_gate::AclRepairOutcome {
use crate::acl_repair_gate::AclRepairOutcome;
let executable = match std::env::current_exe() {
Ok(executable) => executable,
Err(error) => {
return AclRepairOutcome::Failed(format!("定位 AGC ACL 修复程序失败:{error}"));
}
};
if !executable.is_file() {
return AclRepairOutcome::Failed("AGC ACL 修复程序不存在".to_string());
}
let nonce = match create_windows_acl_repair_authorization(repair_path, target_user_sid, scope) {
Ok(nonce) => nonce,
Err(error) => {
return AclRepairOutcome::Failed(format!("准备 AGC ACL 提权授权失败:{error}"));
}
};
let escaped_executable = executable.to_string_lossy().replace('\'', "''");
let arguments = windows_acl_repair_argument_list(
&repair_path.to_string_lossy(),
@@ -2824,19 +2899,20 @@ fn attempt_elevated_windows_acl_repair(
script.as_str(),
])
.creation_flags(0x0800_0000)
.status()
.map_err(|error| format!("启动 AGC ACL 提权修复失败:{error}"));
.status();
let _ = windows_acl_repair_authorization_path(&nonce).and_then(|authorization_path| {
fs::remove_file(authorization_path).map_err(|error| error.to_string())
});
let status = status?;
if status.success() {
Ok(())
} else {
Err(format!(
match status {
Err(error) => AclRepairOutcome::Failed(format!("启动 AGC ACL 提权修复失败:{error}")),
Ok(status) if status.success() => AclRepairOutcome::Repaired,
Ok(status) if status.code() == Some(1_223) => AclRepairOutcome::Denied(
"AGC ACL 提权修复被用户取消(exit code Some(1223)".to_string(),
),
Ok(status) => AclRepairOutcome::Failed(format!(
"AGC ACL 提权修复未成功(exit code {:?}",
status.code()
))
)),
}
}
@@ -106,6 +106,7 @@ fn register_non_canonical_asset_kind_reporter() {
// 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。
include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs"));
mod acl_repair_gate;
mod agent;
mod agent_native_tools;
mod analytics;
@@ -1457,7 +1458,9 @@ const GAME_CREATOR_AGENT_MODE_CODEX_CLI: &str = "codex_cli";
const GAME_CREATOR_AGENT_MODE_PROVIDER: &str = "provider";
const GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION: &str = "game-creator-config.v2";
const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://dev.genarrative.world/gpt/v1";
const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-6-astra";
// 默认模型不再写死具体上游模型名:正式构建锁定官方路由,
// 未选择平台目录模型时该占位标识表示“跟随平台默认”(与 config.rs 同源)。
const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = OFFICIAL_LLM_ROUTER_DEFAULT_MODEL;
const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses";
const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "high";
const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000;
@@ -2685,6 +2688,7 @@ fn main() {
install_platform_account_session,
clear_platform_account_session,
read_game_creator_app_config,
clear_game_creator_acl_elevation_denials,
write_game_creator_app_config,
select_game_creator_model,
discover_game_creator_llm_models,
@@ -0,0 +1,376 @@
use super::*;
use crate::acl_repair_gate::{
AclRepairGate, AclRepairGateResult, AclRepairOutcome, AclRepairPolicy,
};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
fn test_policy() -> AclRepairPolicy {
AclRepairPolicy {
success_cooldown: Duration::from_secs(30),
denial_cooldown: Duration::from_secs(300),
failure_cooldown: Duration::from_secs(15),
wait_timeout: Duration::from_secs(5),
leader_deadline: Duration::from_secs(300),
}
}
fn test_key(target: &str) -> (String, &'static str) {
(target.to_string(), "managed")
}
#[test]
fn concurrent_requests_for_one_target_run_the_repair_once() {
let gate = Arc::new(AclRepairGate::new());
let executions = Arc::new(AtomicUsize::new(0));
let started_at = Instant::now();
let handles = (0..8)
.map(|_| {
let gate = Arc::clone(&gate);
let executions = Arc::clone(&executions);
std::thread::spawn(move || {
gate.run(test_key("c:\\target"), started_at, &test_policy(), || {
executions.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(150));
AclRepairOutcome::Repaired
})
})
})
.collect::<Vec<_>>();
let results = handles
.into_iter()
.map(|handle| handle.join().expect("提权闸门线程不得 panic"))
.collect::<Vec<_>>();
assert_eq!(executions.load(Ordering::SeqCst), 1);
assert_eq!(
results
.iter()
.filter(|result| matches!(result, AclRepairGateResult::Executed(_)))
.count(),
1
);
assert_eq!(
results
.iter()
.filter(|result| matches!(
result,
AclRepairGateResult::Reused(AclRepairOutcome::Repaired)
))
.count(),
7
);
}
#[test]
fn different_targets_are_not_deduplicated() {
let gate = AclRepairGate::new();
let executions = AtomicUsize::new(0);
let now = Instant::now();
for target in ["c:\\one", "c:\\two"] {
let result = gate.run(test_key(target), now, &test_policy(), || {
executions.fetch_add(1, Ordering::SeqCst);
AclRepairOutcome::Repaired
});
assert!(matches!(result, AclRepairGateResult::Executed(_)));
}
assert_eq!(executions.load(Ordering::SeqCst), 2);
}
#[test]
fn denied_elevation_is_reused_for_the_denial_cooldown() {
let gate = AclRepairGate::new();
let key = test_key("c:\\denied");
let started_at = Instant::now();
let policy = test_policy();
let first = gate.run(key.clone(), started_at, &policy, || {
AclRepairOutcome::Denied("UAC 已取消".to_string())
});
assert!(matches!(
first,
AclRepairGateResult::Executed(AclRepairOutcome::Denied(_))
));
let inside_cooldown = gate.run(
key.clone(),
started_at + Duration::from_secs(60),
&policy,
|| panic!("拒绝冷却期内不得再次触发提权"),
);
assert!(matches!(
inside_cooldown,
AclRepairGateResult::Reused(AclRepairOutcome::Denied(_))
));
let after_cooldown = gate.run(key, started_at + Duration::from_secs(301), &policy, || {
AclRepairOutcome::Repaired
});
assert_eq!(
after_cooldown,
AclRepairGateResult::Executed(AclRepairOutcome::Repaired)
);
}
#[test]
fn successful_repair_and_failure_are_reused_for_their_own_cooldowns() {
let gate = AclRepairGate::new();
let policy = test_policy();
let started_at = Instant::now();
let repaired_key = test_key("c:\\repaired");
assert!(matches!(
gate.run(repaired_key.clone(), started_at, &policy, || {
AclRepairOutcome::Repaired
}),
AclRepairGateResult::Executed(AclRepairOutcome::Repaired)
));
assert_eq!(
gate.run(
repaired_key.clone(),
started_at + Duration::from_secs(29),
&policy,
|| panic!("成功冷却期内不得重复提权")
),
AclRepairGateResult::Reused(AclRepairOutcome::Repaired)
);
assert!(matches!(
gate.run(
repaired_key,
started_at + Duration::from_secs(31),
&policy,
|| { AclRepairOutcome::Repaired }
),
AclRepairGateResult::Executed(_)
));
let failed_key = test_key("c:\\failed");
assert!(matches!(
gate.run(failed_key.clone(), started_at, &policy, || {
AclRepairOutcome::Failed("提权修复退出码 1".to_string())
}),
AclRepairGateResult::Executed(AclRepairOutcome::Failed(_))
));
assert!(matches!(
gate.run(
failed_key.clone(),
started_at + Duration::from_secs(14),
&policy,
|| { panic!("失败冷却期内不得重复提权") }
),
AclRepairGateResult::Reused(AclRepairOutcome::Failed(_))
));
assert!(matches!(
gate.run(
failed_key,
started_at + Duration::from_secs(16),
&policy,
|| { AclRepairOutcome::Repaired }
),
AclRepairGateResult::Executed(_)
));
}
#[test]
fn cooldown_is_measured_from_the_recorded_result_not_the_leader_start() {
// 真机场景:UAC 弹窗被挂着几十秒到两分钟。若冷却从 leader 起跑时刻算,
// 120s 拒绝冷却会在用户应答前就过期,紧接着的自动重查立刻再弹一次。
let gate = AclRepairGate::new();
let key = test_key("c:\\slow-success");
let policy = AclRepairPolicy {
success_cooldown: Duration::from_millis(200),
..test_policy()
};
let executions = AtomicUsize::new(0);
let executed = gate.run(key.clone(), Instant::now(), &policy, || {
executions.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(400));
AclRepairOutcome::Repaired
});
assert_eq!(
executed,
AclRepairGateResult::Executed(AclRepairOutcome::Repaired)
);
let reused = gate.run(key, Instant::now(), &policy, || {
executions.fetch_add(1, Ordering::SeqCst);
panic!("冷却必须从结果落库时刻算起,不能用 leader 起跑时刻")
});
assert_eq!(
reused,
AclRepairGateResult::Reused(AclRepairOutcome::Repaired)
);
assert_eq!(executions.load(Ordering::SeqCst), 1);
}
#[cfg(windows)]
#[test]
fn repair_gate_key_merges_path_spelling_variants_of_one_target() {
use crate::config::{windows_acl_repair_gate_key, WindowsAclRepairScope};
use std::path::Path;
// 最近项目里同一项目会同时出现 `\\?\C:\...` 与 `C:\...` 两种写法(客户端列表实测),
// 不归一化就是两个 key -> 同一个目录弹两次 UAC。
let plain =
Path::new(r"C:\Users\dongy\AppData\Roaming\world.genarrative.ai-game-creator\projects");
let extended =
Path::new(r"\\?\C:\Users\dongy\AppData\Roaming\world.genarrative.ai-game-creator\projects");
let share = Path::new(r"\\server\share\projects");
let share_extended = Path::new(r"\\?\UNC\server\share\projects");
for scope in [
WindowsAclRepairScope::Managed,
WindowsAclRepairScope::UserSelected,
] {
assert_eq!(
windows_acl_repair_gate_key(plain, scope),
windows_acl_repair_gate_key(extended, scope)
);
assert_eq!(
windows_acl_repair_gate_key(share, scope),
windows_acl_repair_gate_key(share_extended, scope)
);
assert_ne!(
windows_acl_repair_gate_key(plain, scope),
windows_acl_repair_gate_key(share, scope)
);
}
assert_ne!(
windows_acl_repair_gate_key(plain, WindowsAclRepairScope::Managed),
windows_acl_repair_gate_key(plain, WindowsAclRepairScope::UserSelected)
);
}
#[test]
fn stale_leader_is_taken_over_and_its_late_result_is_discarded() {
// 真机场景:`Start-Process -Wait` 挂死时,follower 等到 60s 只会失败关闭,
// 而这个 key 会被永久占住(clear_denials 也不清理 running)——只能重启客户端。
// 超过 leader_deadline 必须允许接管,且旧 leader 迟到的结果不得覆盖接管者。
let gate = Arc::new(AclRepairGate::new());
let key = test_key("c:\\stale-leader");
let policy = AclRepairPolicy {
leader_deadline: Duration::from_millis(150),
..test_policy()
};
let started_at = Instant::now();
let slow = {
let gate = Arc::clone(&gate);
let key = key.clone();
std::thread::spawn(move || {
gate.run(key, started_at, &policy, || {
std::thread::sleep(Duration::from_millis(400));
AclRepairOutcome::Failed("卡死的 leader 迟到落库".to_string())
})
})
};
std::thread::sleep(Duration::from_millis(250));
let taken_over = gate.run(key.clone(), Instant::now(), &policy, || {
AclRepairOutcome::Repaired
});
assert_eq!(
taken_over,
AclRepairGateResult::Executed(AclRepairOutcome::Repaired)
);
assert!(matches!(
slow.join().expect("leader 线程不得 panic"),
AclRepairGateResult::Executed(AclRepairOutcome::Failed(_))
));
assert_eq!(
gate.run(key, Instant::now(), &policy, || {
panic!("冷却内必须复用接管者的结果,不得再执行")
}),
AclRepairGateResult::Reused(AclRepairOutcome::Repaired)
);
}
#[test]
fn clearing_denials_allows_an_explicit_user_retry() {
let gate = AclRepairGate::new();
let key = test_key("c:\\denied-cleared");
let started_at = Instant::now();
let policy = test_policy();
gate.run(key.clone(), started_at, &policy, || {
AclRepairOutcome::Denied("UAC 已取消".to_string())
});
gate.clear_denials();
let retried = gate.run(key, started_at + Duration::from_secs(1), &policy, || {
AclRepairOutcome::Repaired
});
assert_eq!(
retried,
AclRepairGateResult::Executed(AclRepairOutcome::Repaired)
);
}
#[test]
fn followers_give_up_when_the_leader_never_finishes() {
let gate = Arc::new(AclRepairGate::new());
let key = test_key("c:\\slow");
let (release_sender, release_receiver) = std::sync::mpsc::channel::<()>();
let leader_gate = Arc::clone(&gate);
let leader_key = key.clone();
let leader = std::thread::spawn(move || {
leader_gate.run(leader_key, Instant::now(), &test_policy(), || {
let _ = release_receiver.recv_timeout(Duration::from_secs(5));
AclRepairOutcome::Repaired
})
});
let policy = AclRepairPolicy {
wait_timeout: Duration::from_millis(50),
..test_policy()
};
let follower = std::thread::spawn(move || {
gate.run(key, Instant::now(), &policy, || {
panic!("follower 不得自行执行提权")
})
});
let follower_result = follower.join().expect("follower 线程不得 panic");
assert_eq!(follower_result, AclRepairGateResult::WaitTimedOut);
release_sender.send(()).expect("放行 leader");
assert!(matches!(
leader.join().expect("leader 线程不得 panic"),
AclRepairGateResult::Executed(AclRepairOutcome::Repaired)
));
}
#[test]
fn leader_panic_releases_followers_instead_of_letting_them_wait() {
let gate = Arc::new(AclRepairGate::new());
let key = test_key("c:\\panicking");
let (entered_sender, entered_receiver) = std::sync::mpsc::channel::<()>();
let leader_gate = Arc::clone(&gate);
let leader_key = key.clone();
let leader = std::thread::spawn(move || {
leader_gate.run(leader_key, Instant::now(), &test_policy(), || {
entered_sender.send(()).expect("通知 follower");
panic!("提权执行线程异常退出");
})
});
entered_receiver
.recv_timeout(Duration::from_secs(5))
.expect("leader 已进入执行");
let follower_gate = Arc::clone(&gate);
let follower = std::thread::spawn(move || {
follower_gate.run(key, Instant::now(), &test_policy(), || {
panic!("follower 不得自行执行提权")
})
});
assert!(leader.join().is_err());
let follower_result = follower.join().expect("follower 线程不得 panic");
assert!(matches!(
follower_result,
AclRepairGateResult::Reused(AclRepairOutcome::Failed(_))
));
}
@@ -554,6 +554,11 @@ fn canonical_agent_reasoning_effort_defaults_are_exhaustive_and_auditable() {
template.llm.as_ref().and_then(|llm| llm.max_retries),
Some(DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES)
);
assert_eq!(
template.llm.as_ref().and_then(|llm| llm.model.as_deref()),
Some(DEFAULT_GAME_CREATOR_LLM_MODEL),
"首次启动模板必须写入官方路由占位模型,不能钉死具体上游模型名"
);
assert!(
template.agent_llm.unwrap_or_default().is_empty(),
"bundled template must not persist canonical defaults as explicit overrides"
@@ -6150,6 +6150,7 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() {
fs::remove_dir_all(root).ok();
}
mod acl_repair_gate;
mod asset_delete;
mod asset_rename;
mod collaboration;
@@ -0,0 +1,23 @@
import { resolveTauriInvoke } from '../../app/tauri';
/**
* 用户主动操作(打开/新建项目、选择目录、重命名刷新)时调用:解除 Rust 侧的 ACL 提权拒绝记忆。
*
* Rust 侧闸门对「用户取消 UAC」有 120s 冷却,冷却期内同一目标的提权请求直接复用拒绝结果、
* 不再弹窗。所以只要入口是明确的用户动作,就必须先清掉这份记忆,否则用户会看到
* 「点了打开却立刻失败、也不问我要不要授权」。
*
* 零成本失败关闭:不在 Tauri 环境直接返回;命令失败也只吞掉(下一次用户操作会再试),
* 不能让「重置拒绝记忆」这种旁路动作影响本次操作本身。
*/
export function clearAclElevationDenials() {
const invoke = resolveTauriInvoke();
if (!invoke) {
return;
}
try {
void invoke('clear_game_creator_acl_elevation_denials').catch(() => {});
} catch {
// 命令缺失等同步异常同样不影响本次操作:这只是一次旁路清零。
}
}
@@ -54,6 +54,7 @@ import {
projectPathHasControlCharacter,
} from '../project-summary/projectSummary';
import { importDesignFiles } from '../project-workspace/importDesignFiles';
import { clearAclElevationDenials } from './aclElevation';
import {
ensureHomeWebCreationEnvironment,
HOME_WEB_PREFLIGHT_FAILURE,
@@ -614,6 +615,9 @@ export function useHomeProjectCreation({
mode: 'open' | 'create',
analytics?: ProjectOpenAnalytics,
) {
// 打开/新建是明确的用户动作:先解除 Rust 侧的提权拒绝记忆,否则 120s 冷却内
// 首条 inspect_local_project_directory 会直接复用「用户取消」的结果,既不弹 UAC 也打不开。
clearAclElevationDenials();
if (mode === 'create') {
await createProjectFromProjectPage(nextProjectPath);
return;
@@ -13,6 +13,7 @@ import {
isAbsoluteProjectPath,
projectPathHasControlCharacter,
} from '../project-summary/projectSummary';
import { clearAclElevationDenials } from './aclElevation';
import {
buildRecentProjectRows,
readRecentWorkspaces,
@@ -27,10 +28,12 @@ const RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS = [300];
const RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS = [15_000, 45_000, 120_000];
/**
* 提权/权限类失败不重试:Rust 侧会重新走 `Start-Process -Verb RunAs -Wait`
* 而提权闸门只存在于单次 invoke 内,重试等于在用户刚点「否」后再弹一次 UAC。
* 判据与 config.rs 的 `windows_acl_error_may_need_elevation` 同口径。
* 重试等于在用户刚点「否」后再弹一次 UAC。
* `AGC_ACL_ELEVATION_DENIED` 是 Rust 侧用户取消 UAC 的稳定标记(config.rs),
* 其余为 ACL/DACL 判据与历史文案,与 `windows_acl_error_may_need_elevation` 同口径。
*/
const RECENT_WORKSPACE_ELEVATION_ERROR_MARKERS = [
'AGC_ACL_ELEVATION_DENIED',
'DACL',
'权限',
'error 5',
@@ -223,7 +226,7 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
}, [recentWorkspaces, recentWorkspaceRefreshKey]);
function rememberRecentWorkspace(projectPath: string) {
// 用户主动打开或新建项目:解除提权类失败的跳过标记。
clearAclElevationDenials();
nonRetryablePathsRef.current.clear();
setRecentWorkspaces(writeRecentWorkspace(projectPath));
setRecentWorkspaceRefreshKey((current) => current + 1);
@@ -234,6 +237,7 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
if (!invoke) {
return;
}
clearAclElevationDenials();
nonRetryablePathsRef.current.delete(projectPath);
const inspection = await inspectRecentWorkspaceWithRetry(
invoke,
+6 -7
View File
@@ -10975,14 +10975,13 @@ button.design-workspace-tree__entry:hover,
bottom: calc(100% + 8px);
z-index: 20;
display: grid;
/* 目录项就是上游原始模型名长度不可控菜单按最宽条目自动拓宽锚在触发钮右缘
向左侧生长不再用固定 150190px 把名字截掉只有极端长名字才受视口宽度限制 */
width: max-content;
min-width: 150px;
max-width: 190px;
/* 条目多时菜单不能无限长240px 与视口 40vh 取小者超出部分在菜单内滚动
窄屏 / 移动端优先下 40vh 更稳滚动不外溢给背后的消息列表
`.resource-reference-menu` 同一口径 */
max-height: min(240px, 40vh);
overflow: auto;
overscroll-behavior: contain;
max-width: calc(100vw - 24px);
/* 目录规模由后台维护当前是上游在售的个位数模型菜单按内容高度展开
不再设 max-height因此不会出现滚动条 */
padding: 5px;
border: 1px solid var(--platform-surface-border, #e5e7eb);
border-radius: 10px;
@@ -2883,6 +2883,9 @@ export function registerRecentProjectsTests() {
'厨房突围',
);
}
if (command === 'clear_game_creator_acl_elevation_denials') {
return undefined;
}
if (command === 'open_game_creator_workspace_window') {
return undefined;
}
@@ -2986,10 +2989,25 @@ export function registerRecentProjectsTests() {
{ projectPath: '/tmp/broken-status' },
);
// 打开是明确的用户动作:必须先解除 Rust 侧的提权拒绝记忆,再 inspect。
// 否则 120s 拒绝冷却内首条 inspect 直接复用「用户取消」的结果:既不弹 UAC,也打不开项目。
const callsBeforeOpen = invoke.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: '打开项目 厨房突围' }));
await waitFor(() => {
expect(screen.getByLabelText('陶泥儿项目对话')).not.toBeNull();
});
const openedCalls = invoke.mock.calls.slice(callsBeforeOpen);
const clearedAt = openedCalls.findIndex(
([command]) => command === 'clear_game_creator_acl_elevation_denials',
);
const inspectedAt = openedCalls.findIndex(
([command, args]) =>
command === 'inspect_local_project_directory' &&
(args as { projectPath?: string } | undefined)?.projectPath ===
'/tmp/ok-game',
);
expect(clearedAt).toBeGreaterThanOrEqual(0);
expect(inspectedAt).toBeGreaterThan(clearedAt);
expect(screen.getByLabelText('项目开发工作台')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
@@ -297,7 +297,7 @@ test('only displays aliases and persists selection through the native command',
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByRole('button', { name: '对话模型' });
expect(screen.queryByText('gpt-6-astra')).toBeNull();
expect(screen.queryByText('quality')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(screen.getByRole('option', { name: '快速' }));
await waitFor(() =>
@@ -206,3 +206,30 @@ test('提权类失败不重试:不放大 UAC 弹窗', async () => {
});
expect(attempts).toBe(1);
});
test('Rust 侧取消 UAC 的稳定标记同样不触发重试', async () => {
let attempts = 0;
const invoke = vi.fn(
async (command: string, _args?: Record<string, unknown>) => {
if (command !== 'inspect_local_project_directory') {
throw new Error(`unexpected invoke ${command}`);
}
attempts += 1;
throw new Error(
'AGC_ACL_ELEVATION_DENIEDAGC ACL 提权修复被用户取消(exit code Some(1223)',
);
},
);
window.__TAURI__ = { core: { invoke } };
window.localStorage.setItem(
'genarrative-ai-game-creator.recent-workspaces.v1',
JSON.stringify(['/tmp/denied-elevation-project']),
);
const { result } = renderHook(() => useRecentProjects(vi.fn()));
await waitFor(() => {
expect(result.current.projectRows[0]?.status).toBe('检查失败');
});
expect(attempts).toBe(1);
});
@@ -0,0 +1,36 @@
# AGC 模型目录上游同步实施计划
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | in-progress |
| Date | 2026-09-24 |
| Parent Milestone | `docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md` |
## 修改边界与顺序
1. **领域模型(`module-runtime/src/agc_models.rs`**:删除写死的 `Default` 实现(原 `quality → gpt-6-astra``fast → gpt-5.6-luna`),新增 `from_upstream_models`:按上游模型名排序去重后生成目录项(`modelId`/`alias` = 上游原名,`id` = 模型名 slug`enabled = true`),默认项取排序后第一项;新增 `resolve_requested`(未选或 `platform-default` 用默认项)。字段、校验规则(32 项上限、id/alias/model_id 约束)与 `resolve` 保持原样。
2. **procedure`spacetime-module/src/agc_models.rs`**`read_agc_model_catalog` 缺行返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`,不再返回内置目录;`save_agc_model_catalog` 不变。无表结构变化,不改 `migration.rs`
3. **api-server 目录模块(`src/agc_models.rs`**:新增启动期 `ensure_agc_model_catalog_initialized`(读 → 解析/校验 → 缺行或非法则 `GET {控制面}/api/pricing?group=taonier` → 生成目录 → 按存量 revision 写回;冲突后重读确认可用);上游请求 10s 超时、1 MiB 流式上限、禁止重定向、不带凭据;未初始化统一 `503` 文案;后台 PUT 增加未初始化门禁。
4. **api-server 接线(`src/main.rs`、`src/external_api_keys.rs`**`try_restore_app_state_for_startup` 按 HTTP 角色调用初始化,失败只 `error!` 记录;抽出 `ensure_llm_router_url_allowed`(只校验地址/scheme,避免被已下线的固定模型哨兵挡住),`LLM_ROUTER_TOKEN_GROUP` / `router_control_origin` 供同步复用。
5. **客户端与后台**:不改。`GET /api/llm/models` 形状、admin DTO、后台「AGC 模型」页、客户端 `select_game_creator_model` 校验全部保持原样。
## 不改的部分
目录字段语义、后台 DTO 与页面、公开 DTO 形状、客户端模型标识校验、`/api/external/v1` 与 OpenAPI、SpacetimeDB 表结构、Router provisioning/额度。
## 验证命令
- `cargo test --locked -p module-runtime --lib agc_models::`
- `cargo test --locked -p api-server --bin api-server agc``... llm::`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins configuration::`
- `cargo fmt --all -- --check`(两套 workspace)、`npx vitest run apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx`、admin-web 页面定向 Vitest 与 typecheck
- `npm run check:encoding``npm run check:doc-index``npm run check:spacetime-schema``git diff --check`
- 运行时 smoke:本地 dev 栈清空 `agc_model_catalog` 后启动 api-server,确认日志 `已按上游模型列表初始化 AGC 模型目录`、库中 `catalog_json` 为「slug id + 上游原名 alias/modelId」、`GET /api/llm/models` 返回原名;再把上游地址指向不可达端口验证 `503` 与「无替代目录」。
## 风险与回滚点
- **上游端点与鉴权**:分组定价列表端点为实测确认的公开只读接口;若上游改版,同步失败只会让目录保持未初始化(接口 503 + 启动 error),不会写入错误模型。
- **混合版本**:module 的缺行语义变化要求 module 与 api-server 同批发布/回滚;未升级的 api-server 会把自己的 AGC 接口打到 `503`(后台 DTO 未变,admin-web 可独立发布)。回滚点必须同时覆盖 module 与 api-server。
- **存量目录**:结构合法的旧目录(含 `quality/fast`)不会自动重建,需要 owner 在后台修改或清空该行后重启。
- **目录规模**:目录项上限仍是 32;上游在售模型超过 32 条时同步会失败并记录原因,需要 owner 在后台维护子集。
@@ -0,0 +1,56 @@
# AGC 模型目录初始值改为上游同步
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | in-progress(实现与本地真实上游验证完成;生产发布未执行) |
| Date | 2026-09-24 |
| Parent Spec | `docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md` |
## 背景与触发
`agc_model_catalog` 缺行时,`read_agc_model_catalog` 兜底返回写死的初始目录(`高质量 → gpt-6-astra``快速 → gpt-5.6-luna`)。这两个模型已从上游 Router 移除,于是从未配置过目录的环境(新库、清库、本地调试)会把两个不存在的模型下发给客户端,选中后上游 `model_not_found`,必须人工在后台保存一次目录才恢复。
## 目标
1. 初始目录不再写死:api-server 启动期从上游分组定价列表生成,`alias``modelId` 都用上游原始模型名(不再填“高质量/快速”这类人工别名)。
2. 拉不到就报错、不写替代目录,并在下一次启动继续重试,直到目录里有数据。
3. **保持既有格式与契约不变**:目录字段(`id`/`alias`/`modelId`/`defaultModelId`)、后台页面与 DTO、`GET /api/llm/models` 形状、客户端模型标识校验都不变,不引入不兼容变更。
## 不在本里程碑内
- 不改目录字段语义与后台维护方式,不删别名/稳定标识概念。
- 不做上游变化的自动跟随同步(由 owner 在后台维护)。
- 不改 Router provisioning、额度与计费链路。
- 不改 `/api/external/v1` 与 OpenAPI,不改 SpacetimeDB 表结构。
## 合同要点
- **初始化**api-serverAPI/All 角色)启动时目录缺失、结构与当前定义不符或校验不通过即视为未初始化;此时请求 `GET {Router 控制面}/api/pricing?group=taonier`(公开只读、不带凭据),取 `data[].model_name`,按模型名排序生成目录:`modelId``alias` 为上游原名、`id` 为模型名 slug(小写字母/数字/`-`/`_`,同名冲突追加 `-2`)、全部 enabled、默认项取排序后第一项,并以存量 revision 写回自增。
- **失败关闭**:拉取失败、空列表、响应超过 1 MiB、缺可解析 revision、写回失败都只记录 error,不写替代目录;未初始化期间 AGC 目录/对话接口与后台目录接口返回 `503`“模型目录未初始化”。
- **重试口径**:只启动期尝试一次;失败不阻塞启动,下次启动重试,直到目录里有数据。请求侧无法触发同步。
- **幂等与并发**:目录只取决于模型集合(排序后生成),重复同步一致;多实例并发只有一个写入成功,冲突方接受既有目录并校验其可用性。
- **存量目录**:结构合法的目录不会被自动重建(包括旧版写死的 `quality/fast`),需要 owner 在后台改掉或清空该行后重启。
## 依赖
- `module-runtime``AgcModelCatalog::from_upstream_models`(slug 生成 + 默认项 + 校验),删除写死的 `Default` 实现。
- `spacetime-module``read_agc_model_catalog` 缺行返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`
- `api-server`:启动期 `ensure_agc_model_catalog_initialized`;上游请求硬化(10s 超时、1 MiB 流式上限、禁止重定向、不带凭据);`ensure_llm_router_url_allowed`(只校验地址,不绑定已下线的固定模型)。
- 文档:主规范、后端数据契约、运维文档、decision-log。
## 验收标准
1. 空目录 + 上游可达:启动后目录自动生成(别名即上游原名),`revision` 自增一次,`GET /api/llm/models``displayName` 是上游原名,界面不出现内置模型名。
2. 空目录 + 上游不可达/非 2xx/空列表:启动只记录 error、不写替代目录;AGC 与后台目录接口 `503`;上游恢复后重启即同步成功。
3. 幂等:同一模型集合重复同步得到一致的目录与默认项。
4. 目录领域校验(id/alias/model_id、32 项上限、默认项必须启用)与请求侧 `422`/`409` 行为与改动前一致。
5. 回归:Rust 定向测试、AGC/admin-web 类型检查与定向测试、`npm run check:encoding``check:doc-index``check:spacetime-schema``git diff --check`
## 已决与待决
- 已决:上游来源用分组定价列表(2026-09-24 实测:`/v1/models` 用管理 token 返回 401;管理面注册表会带出已下线、无路由绑定的模型)。
- 已决:`id` 用模型名 slug,保持客户端标识校验契约不变。
- 已决:初始化失败不阻塞启动,只在下次启动重试。
- 已决:目录结构与既有 DTO/页面保持不变,本变更不引入不兼容改动。
- 待决:是否需要“上游自动跟随同步”(当前不做)。
@@ -97,6 +97,17 @@
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_turn_error.rs,direct_turn_failure.rs,direct_delivery.rs,direct_runtime/mod.rs,direct_runtime/user_input.rs,codex_app_server/mod.rs,codex_app_server/execution.rs}``apps/ai-game-creator-shell/src-tauri/src/cli.rs`;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`
- 验证:`cargo test --bins -- direct_`460 passed)、`cargo test --bins -- codex_app_server`100 passed)、`cargo fmt --check``npm run check:encoding`、定向 `git diff --check`。整包 `cargo test --bins` 在本机被既有 `tests::provider` / `tests::project` 重型用例挂住(并发跑测试时另有一条锁竞争用例会假失败),非本次改动引入。真实客户端观感未复核。
## 2026-09-23 ACL 提权修复按目标做 single-flight
- 背景:`windows_acl_repair_target` 对 Managed 作用域返回的是「第一个读取被拒的祖先」,同一祖先下的多个项目会解析到**同一个** repair target;而唯一的去重只是单次调用内的局部 `attempted_targets`。于是启动页一次挂载(≤8 个最近项目并发检查)会启动同样多次 `powershell -Verb RunAs`,用户看到叠在一起的 UAC 弹窗(issue #498)。
- 决策:新增进程级闸门 `acl_repair_gate`key = `(规范化 repair target, scope)`。并发调用只允许一次真实提权,其余等待并复用**同一结果**;结果在冷却窗口内直接复用(成功 30s / 失败 15s / 用户取消 120s),等待窗口 60s 超时按失败关闭。leader 异常退出由 RAII 兜底记为失败并唤醒全部等待者,避免等待者被永久挂住。
- 决策补充(key 归一化):key 的路径半边经 `windows_acl_repair_gate_key` 归一化——去掉 `\\?\` / `\\?\UNC\` 前缀并统一小写。最近项目列表里同一项目实测同时存在 `\\?\C:\...``C:\...` 两种写法(客户端 localStorage 实测),不归一化就是两个 key,同一个目录仍会弹两次 UAC。这里刻意只做前缀与大小写归一而不 `canonicalize`:待修复目标恰恰是「读不动的目录」,解析不可靠。
- 决策补充(冷却基准):冷却从**结果落库**时刻算起,不是 leader 起跑时刻。UAC 弹窗会被挂着几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,前端 15s/45s/120s 的整表重查紧跟着再弹一次。
- 决策补充(leader 失效接管):`leader_deadline`(默认 5 分钟)之后,新调用可以接管仍是 `running` 的 key;每个 leader 带令牌,被接管后旧 leader 迟到的结果直接丢弃,不会覆盖接管者的结果。真机上无人应答的 UAC 约 2 分钟自然超时,所以这个上限只兜「提权子进程真挂死」——否则该目标会永久按失败关闭(`clear_denials` 不清理 running,只能重启客户端)。
- 错误类型化:用户取消 UAC 的错误统一带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此判定「不可自动重试」,不再依赖中文文案匹配。
- 用户主动操作(打开/新建项目、文件选择器选择目录、重命名刷新)会调用 `clear_game_creator_acl_elevation_denials` 清除拒绝记忆,保证显式重试仍能再次请求提权。前端唯一入口是 `features/app-shell/aclElevation.ts``clearAclElevationDenials()`:最近项目 hook`rememberRecentWorkspace` / `refreshRecentWorkspace`)与打开/新建链路(`useHomeProjectCreation.openProject`,覆盖行内打开与 picker)共用它;漏挂入口会让用户「点了打开立即失败、也不问授权」。
- 未做:给提权子进程加有界等待(`Start-Process -Wait` 目前无超时)。理由:中断挂起的 UAC 流程比等待更糟,single-flight 已把并发弹窗收成一个,follower 的等待由 60s 窗口兜底。
## 2026-09-24 DirectProject 状态条口径翻转、几何约束与对话 Markdown 容错
- 背景:AGC DirectProject 对话区底部的「陶泥儿正在处理 / 已耗时 12.4秒」状态条同时退化三处:① 读秒 1 秒一跳(耗时文案不足一分钟显示一位小数,小数位却一秒才动一格);② 窗口压矮时被挤扁(300px 高压到 33px、240px 时 24px,文字被 `overflow: hidden` 裁掉);③ `turn.started` 之前(模型首 token 前,实测约十秒)整条卡片不出现,界面没有任何「正在处理」的交代。同批还修了对话 Markdown 的两处代码块问题(不换行把消息拉宽、粘在正文行里的围栏导致代码块解析错位)。
@@ -9500,3 +9511,17 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 代价(已知并接受):接单窗口与订阅重建窗口里聊天区没有这一轮的显示,反馈只有 composer 忙态、状态行与「陶泥儿正在处理」卡片(卡片这一段还读不出「已耗时」——起点是宿主的 `turn.started.at`,开始事件到了才开始读秒);`project.jsonl` 用户条目依旧只在首屏 / 翻页读进前端。
- 影响面:`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/{directTurnPresentation.ts,directCodexConversation.ts}``.../chat/controller/{useDirectProjectChatController.ts,useDirectProjectTurnStatus.ts}``.../chat/DirectProjectChatView.tsx``.../chat/components/DirectProjectConversation/DirectProjectTurn.tsx``src-tauri/src/agent/codex_app_server/mod.rs`(用户条目时间的注释口径)、对应 ADR 与实施计划。
- 验证:`npx vitest run tests/directTurnPresentation.test.ts tests/directProjectTurn.test.tsx tests/directProjectTurnStatus.test.ts tests/directThreadChat.test.ts tests/chatComposerAttachmentCap.test.tsx``tests/appSurface.test.ts`213 passed / 9 skipped)、`npx tsc -p tsconfig.json --noEmit``eslint``prettier --check``npm run check:encoding``git diff --check` 全绿。真实客户端观感未复核。
## 2026-09-24 AGC 模型目录初始值改为上游同步:不再回退写死的 gpt-6-astra/gpt-5.6-luna
- 背景:`agc_model_catalog` 缺行时 procedure 兜底返回内置目录(`quality → gpt-6-astra``fast → gpt-5.6-luna`),两个模型都已从上游移除;从未配置过目录的环境(新库、清库、本地调试)会把不存在的模型下发给客户端,选中后上游 `model_not_found`
- 决策(范围):本次只改目录初始值的来源,保持既有格式与契约不变 —— 目录字段仍是 `id`/`alias`/`modelId`/`defaultModelId`,后台页面与 admin DTO、`GET /api/llm/models` 形状、客户端 `select_game_creator_model` 的标识校验都不动,因此没有不兼容变更。
- 决策(初始化):api-server(API/All 角色)启动时目录缺失、结构与当前定义不符或校验不通过即视为未初始化;此时请求上游 Router 控制面的分组定价列表 `GET {控制面}/api/pricing?group=taonier`(公开只读、不带凭据),按 `data[].model_name` 排序生成目录:`modelId``alias` 用上游原名(不再填“高质量/快速”),`id` 用模型名 slug(小写字母/数字/`-`/`_`,同名冲突追加 `-2`,因此客户端标识校验无需放宽),全部 enabled,默认项取排序后第一项,并按存量 revision 写回自增。
- 决策(来源选择,2026-09-24 实测后确定):不用管理面模型注册表 `/api/models/`(会带出已下线、没有路由绑定的 `gpt-6-astra`/`gpt-6-luna`),也不用 `/v1/models`(要求 Router 用户 Key,用管理 token 实测 401)。当日 `group=taonier` 在售 6 个:`deepseek-flash``deepseek-v4-pro``glm-5.3``glm-5.3-flash``qwen-image-3.0``qwen3.8-flash`
- 决策(失败关闭与重试):拉取失败、空列表、响应超 1 MiB、缺可解析 revision 或写回失败都只记录 error,不写替代目录;未初始化期间 `GET /api/llm/models``/api/llm/responses``/api/llm/chat/completions` 与后台 `GET/PUT /admin/api/agc-models` 返回 `503`“模型目录未初始化”;只在启动期尝试一次,下一次启动重试,直到目录里有数据。启动本身不因同步失败而失败,避免 Router 短时不可用放大成 api-server 起不来。
- 决策(幂等与并发):目录只取决于模型集合(排序后生成),重复同步结果一致;多实例并发启动只有一个写入成功,冲突方重读并校验既有目录可用性。目录只在未初始化时重建,上游变化不自动跟随。
- 决策(存量目录):结构合法的目录不会被自动重建,包括旧版写死的 `quality/fast` —— 需要 owner 在后台改掉,或清空该行后重启重新同步。
- 影响范围:`module-runtime``from_upstream_models` + slug 生成,删除写死的 `Default`)、`spacetime-module`(缺行返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`)、`api-server`(启动期同步、上游请求硬化、只校验地址的目标校验、后台 PUT 未初始化门禁)、AGC 客户端(默认模型占位改为 `platform-default`)、AGC 模型弹层 CSS、主规范/后端契约/运维文档。
- 验证:`cargo test -p module-runtime --lib agc_models::`4 passed)、`cargo test -p api-server --bin api-server agc``llm::`、AGC 客户端 `configuration::`、admin-web 页面定向 Vitest 与 typecheck、两套 workspace 的 `cargo fmt -- --check``check:encoding`/`check:doc-index`/`check:spacetime-schema`/`git diff --check`
- 验证(真实上游 smoke,本地 dev DB):清空 `agc_model_catalog` 后启动 api-server → 日志 `已按上游模型列表初始化 AGC 模型目录 revision=1 model_count=6`;登录后 `GET /api/llm/models` 返回同一批模型、`displayName` 即上游原名、默认项为排序后第一项;上游不可达/非 2xx 时启动只记录 error、AGC 接口 `503` 且目录保持未初始化;目录已存在时重启不重写。
- 边界(未验证/残留):上游在售模型超过 32 条时同步会失败(目录项上限未改);`qwen-image-3.0` 这类图像模型会一起进入目录,是否对 AGC 隐藏由 owner 在后台停用;混合版本期间未升级的 api-server 会把自己的 AGC 接口打到 `503`module 与 api-server 必须同批发布/回滚。
@@ -1,5 +1,15 @@
# 踩坑与排障记录
## 同一祖先下的多个项目会各自弹一次 UAC
- **现象**:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。
- **原因**`windows_acl_repair_target``src-tauri/src/config.rs`)对 Managed 作用域返回「第一个读取被拒的祖先」——同一祖先下的多个项目解析到**同一个** repair target;而唯一的去重是单次调用内的局部 `attempted_targets`,跨调用、跨线程都没有记忆。启动页一次并发检查 ≤8 个最近项目,就会并发启动同样多次 `powershell -Verb RunAs`
- **处理**:进程级 single-flightkey = `(规范化 repair target, scope)`)+ 结果冷却(成功 30s / 失败 15s / 用户取消 120s)+ 等待窗口 60s 超时按失败关闭;leader 异常退出由 RAII 兜底唤醒等待者。用户取消带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此不自动重试;用户主动操作会清除拒绝记忆。
- **不要踩的坑**:① 闸门 key 必须归一化 `\\?\` / `\\?\UNC\` 前缀——最近项目列表里同一项目实测同时存在 `\\?\C:\...``C:\...` 两种写法,按原始字符串做 key 会让同一个目录弹两次 UAC(`windows_acl_repair_gate_key`);② 冷却必须从**结果落库**时刻算起,用 leader 起跑时刻会让 120s 拒绝冷却在 UAC 被挂着两分钟时提前过期,紧接着的自动重查立刻再弹一次;③ 复现「多个项目共用同一 target」时,DENY 要写在祖先的**父目录**上靠继承落入祖先——`icacls` 直接加在容器自身实测只影响子项(容器自身 `GetFileAttributes` 仍成功),target 会退化成每个项目自己,repro 不出并发弹窗;④ 夹具路径必须落在 `game_creator_private_path_allows_auto_elevation` 放行范围内(runtime config dir / `.config/genarrative` / 打包 AppData / 带 `.agent/manifest.json` 的项目根),因为提权子进程会按 **repair target** 再校验一次 `scope.allows_path`,否则失败关闭。
- **验证**`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者、冷却基准、路径写法归一、leader 卡死接管与迟到结果丢弃)。真机复现(无需提权交互即可计数):在 Managed 放行范围内建 8 个带 `.agent/manifest.json` 的假项目 → 对共同祖先的**父目录** `icacls <父目录> /deny *<sid>:(OI)(CI)(RX)` → 挂载启动页,同时数 `powershell.exe` 里命令行带 `RunAs` 的进程数(`Start-Process -Wait` 会让它一直存活到用户应答)与 `consent.exe` 峰值:修复前 8 个并发请求,修复后 1 个;把同一目录的 `\\?\C:\...``C:\...` 两种写法一起塞进最近项目,还能验证 key 归一化是否生效(修复前 2 个、修复后 1 个)。
- **leader 卡死的兜底**:闸门只有 follower 的有界等待(60s),若提权子进程真的挂死(`Start-Process -Wait` 无超时),`leader_deadline`(5 分钟)之前该 key 一直被占住,之后新调用会接管并按新 leader 执行;被接管后旧 leader 迟到的结果按令牌丢弃,不会覆盖接管者。`clear_game_creator_acl_elevation_denials` 只清「被拒绝」记忆,不清理 running。
- **关联**`src-tauri/src/acl_repair_gate.rs``src-tauri/src/config.rs`、issue #498
> 策划历史条目边界:旧策划 V1/V2 已全部退役,当前入口仅使用 Design Agent。下文带日期的旧 Planning V2、Fast GDD、`plan.submit_gdd`、旧 IPC/模块记录仅用于追溯,不能作为恢复旧代码、身份门禁或专属测试的依据;共享问题需在现役调用上核查。现行合同见[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。
## 2026-09-24 模型输出的围栏会粘在正文行里:聊天 Markdown 必须先归一化再解析
@@ -1,5 +1,7 @@
# AGC 后台模型别名与对话选择
更新时间:`2026-09-24`。本次只改“目录初始值从哪来”:缺配置时不再回退写死的 `高质量 → gpt-6-astra``快速 → gpt-5.6-luna`,改为启动期从上游同步(这两条初始目录里的模型已从上游移除)。目录结构、后台维护字段和客户端契约都保持不变。
## 本地自定义 LLM
- 本地 `game-creator.config.json``llm.customEnabled` 默认 `false`;显式设为 `true` 后,常用设置展示 API 地址、API Key、读取模型列表与勾选区域。DirectProject 沿用 OpenAI Responses 协议,地址填写 API 根地址(例如 `https://provider.example/v1`)。开关只由配置文件控制。
@@ -33,7 +35,11 @@
## 官方路由契约
- 后台 owner 在“AGC 模型”维护列表;每项包含稳定 `id`、必填 `alias`、服务端 `modelId``enabled`。默认项必须启用。标识唯一,别名唯一,列表最多 32 项。
- 配置保存到私有 `agc_model_catalog` 单例表,使用 revision 乐观锁,重启及多 api-server 实例共享同一事实。缺少配置时使用初始目录,高质量对应 `gpt-6-astra`,快速对应 `gpt-5.6-luna`
- 配置保存到私有 `agc_model_catalog` 单例表,使用 revision 乐观锁,重启及多 api-server 实例共享同一事实。
- 目录初始值来自上游同步:api-server(API/All 角色)启动时检查目录,缺失、结构与当前定义不符或校验不通过都算“未初始化”;此时调用上游 Router 控制面的分组定价列表 `GET {Router 控制面}/api/pricing?group=taonier`(控制面地址由 `{LLM Router 地址}` 去掉 `/v1` 得到;公开只读接口,不带凭据),读取 `data[].model_name` 作为“该分组可见的在售模型”,按模型名排序后生成目录:每项 `modelId``alias` 都用上游原始模型名(不再填“高质量/快速”这类人工别名),`id` 是模型名的稳定 slug(小写字母、数字、`-``_`,同名冲突追加 `-2`),`enabled = true`,默认项取排序后第一项,并以存量 revision 写回(`revision` 自增)。不使用管理面模型注册表 `/api/models/`——它会残留已下线、没有路由绑定的条目;也不使用 `/v1/models`——它要求 Router 用户 Key,平台没有服务级 Key。并发启动的多个实例里只有一个写入成功,其余接受既有目录。
- 同步失败(网络、非 2xx、空列表、响应超过 1 MiB、缺少目录行 revision、写回失败)只记录 error 日志,不写任何替代目录、不使用任何内置模型名;本次启动保持未初始化,下一次启动继续重试,直到目录里有数据。
- 目录未初始化时 `GET /api/llm/models``/api/llm/responses``/api/llm/chat/completions` 与后台 `GET/PUT /admin/api/agc-models` 一律失败关闭(`503`),错误文案指向“模型目录未初始化”。恢复路径是修好上游可达性后重启 api-server,或由运维清空 `agc_model_catalog` 该行后再重启。
- 上游变化不自动跟随:目录只在未初始化时重建;上游新增或移除模型由 owner 在后台增删条目或调整启用、默认项。
- `GET/PUT /admin/api/agc-models` 仅 owner 可用,返回完整配置;PUT 携带上次读取的 revision,冲突拒绝覆盖。
- `GET /api/llm/models` 返回启用项的 `id/displayName``defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。
- 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。
@@ -48,6 +54,9 @@
## 验收
- 空目录 + 上游可达:启动后目录自动生成(`id` 为模型名 slug、`alias`/`modelId` 为上游原名、`enabled` 全为真、默认项为排序后第一项),`revision` 自增一次,`GET /api/llm/models``displayName` 就是上游原名,界面不出现任何内置模型名。
- 空目录 + 上游不可达/空列表/非 2xx:启动只记录 error,不生成替代目录;AGC 接口与后台目录接口返回 `503`“模型目录未初始化”;下游可恢复后重启即同步成功(不需要人工造目录)。
- 同一模型集合重复同步结果一致(上游返回顺序不影响目录与默认项)。
- 目录领域校验、未知/停用模型拒绝、客户端响应不包含实际模型名。
- 后台鉴权、持久化 revision 冲突处理;客户端选择保存后重新读取,设置保存不覆盖选择。
- 目录 `revision` 条件刷新与并发触发去重、发送前回退默认模型、刷新失败可恢复。
@@ -507,7 +507,8 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
### `agc_model_catalog`
- 私有单例表,主键 `id=0`,保存 `catalog_json``revision``updated_at`;不存凭据。
- `read_agc_model_catalog` / `save_agc_model_catalog` 只接受已登记的 runtime service identity,保存使用 revision 乐观锁。
- `read_agc_model_catalog` / `save_agc_model_catalog` 只接受已登记的 runtime service identity,保存使用 revision 乐观锁;缺行时读取返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`,不返回任何内置目录
- 目录初始值来自上游同步:api-server(API/All 角色)启动时若目录缺失、结构与当前定义不符或校验不通过,就用分组定价列表 `GET {Router 控制面}/api/pricing?group=taonier`(公开只读、不带凭据)的 `data[].model_name` 生成目录(`id` 为模型名 slug`alias`/`modelId` 为上游原名),失败只记录 error、不写替代目录,由下一次启动重试;未初始化期间 AGC 目录与对话接口、后台目录接口都失败关闭(`503`)。
- 后台 owner 通过 `GET/PUT /admin/api/agc-models` 管理稳定标识、必填别名、实际模型名、启用状态和默认项;客户端 `GET /api/llm/models` 仅返回启用项的稳定标识、别名和目录 `revision`(供条件刷新,不暴露实际模型名)。
- Responses / Chat 请求按目录解析模型;未知或停用项拒绝。AGC 的 `platform-default` 请求标识使用目录默认项。详细契约见 `technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`
@@ -589,6 +589,14 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null
本地联调使用 `dev`,且 `GENARRATIVE_ENV``development`(默认)、`test``container` 时,允许规范 HTTP(S) loopback 地址及可变端口,无需配置独立埋点变量。客户端登录时使用实际 API 入口,容器使用宿主机映射入口。线上部署设置 `GENARRATIVE_ENV=production`,不接受 loopback 例外;详细合同见[客户端本地埋点与主站入库契约](./technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md)第 13 节。
### AGC 模型目录上游同步
`api-server`API/All 角色)启动时检查 `agc_model_catalog`:缺失、结构与当前定义不符或校验不通过都算未初始化,此时请求上游 Router 控制面的分组定价列表 `GET {GENARRATIVE_LLM_ROUTER_BASE_URL 去掉 /v1}/api/pricing?group=taonier`(公开只读接口,不带凭据),按返回的 `data[].model_name` 排序生成目录并写回(revision 自增):`modelId``alias` 都是上游原始模型名,`id` 是模型名的 slug,默认项为排序后第一项。目录结构、后台字段与客户端契约都保持不变。
上游不可达、返回非 2xx、列表为空或响应超过 1 MiB 时,启动日志打印 `AGC 模型目录未初始化:本次启动未从上游同步到模型列表…``GET /api/llm/models``/api/llm/responses` 与后台 `GET/PUT /admin/api/agc-models` 返回 `503`,不返回任何内置模型;修好上游可达性后重启 `api-server` 即会重试成功。目录只在未初始化时重建,上游新增或移除模型由后台「AGC 模型」页维护,不会自动跟随。存量目录(含旧版写死的 `高质量 → gpt-6-astra``快速 → gpt-5.6-luna`)结构合法时不会被自动重建,需要 owner 在后台改掉,或清空 `agc_model_catalog` 该行后重启让其重新同步。
发布与回滚注意:本变更改的是 module 的缺行语义(由“返回内置目录”改为报错)与 api-server 的启动期同步,**module 与 api-server 必须同批发布、同批回滚**;混合版本期间未升级的 api-server 会把自己的 AGC 目录与对话接口打到 `503`(不会崩,但 AGC 不可用)。后台 DTO 与 admin-web 未改动,可独立发布。
### AGC 项目快照上传目标
后台“项目工程”(`/admin/#project-snapshots`)按项目列出远端快照,默认只看本部署渠道,顶部“渠道”选择框可切换远端已存在的其它渠道;列表按游标分页(每页 20/50/100,上一页复用已取得的游标,远端不给总数所以只显示当前页)。完整快照提供“下载完整工程”,按原始目录返回 ZIP;未完成同步的项目暂不可下载,旧清单缺少完整性声明时显示“完整性未知”,只能“下载已存文件”。“用户”列与“素材查询”同口径展示昵称与陶泥号,并可点开用户详情;不要直接把 OSS 的 `files/{size}-{digest}/` 目录下载当成工程。
+363 -14
View File
@@ -7,26 +7,208 @@ use axum::{
extract::{Extension, State},
http::StatusCode,
};
use module_runtime::AgcModelCatalog;
use module_runtime::{
AGC_MODEL_CATALOG_CONFLICT, AGC_MODEL_CATALOG_NOT_INITIALIZED, AgcModelCatalog,
};
use shared_contracts::admin::{AdminAgcModel, AdminAgcModelCatalog};
use spacetime_client::SpacetimeClientError;
use std::time::Duration;
use tracing::warn;
/// 目录未初始化时对外统一的失败文案:目录只能来自上游同步或后台保存。
pub(crate) const AGC_MODEL_CATALOG_NOT_INITIALIZED_MESSAGE: &str =
"模型目录未初始化,服务端正在尝试从上游同步,请稍后重试";
/// 上游模型列表请求超时与响应大小上限;越界按同步失败处理。
///
/// 同步发生在启动期、且在开始对外服务之前,超时必须足够短:上游挂起时不能让
/// 每个 API/All 实例都延迟三十秒才可用。单次失败只记录 error,下次启动会重试。
const AGC_MODEL_LIST_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const AGC_MODEL_LIST_MAX_BYTES: usize = 1024 * 1024;
/// 只读 `revision`:存量目录内容不合法时,覆盖写入仍需对齐乐观锁版本。
#[derive(serde::Deserialize)]
struct StoredCatalogRevision {
revision: u64,
}
pub(crate) async fn load_catalog(state: &AppState) -> Result<AgcModelCatalog, AppError> {
let json = state
.spacetime_client()
.read_agc_model_catalog()
.await
.map_err(|_| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录暂不可用")
})?;
let catalog: AgcModelCatalog = serde_json::from_str(&json).map_err(|_| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录格式无效")
})?;
catalog.validate().map_err(|message| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message)
let stored = read_stored_catalog(state).await.map_err(|_| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录暂不可用")
})?;
let Some(json) = stored else {
return Err(uninitialized_error());
};
parse_catalog(&json).map_err(|_| uninitialized_error())
}
fn uninitialized_error() -> AppError {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message(AGC_MODEL_CATALOG_NOT_INITIALIZED_MESSAGE)
}
/// 解析并校验目录内容;解析或校验失败都按“未初始化”处理,由启动期重新同步。
fn parse_catalog(json: &str) -> Result<AgcModelCatalog, String> {
let catalog: AgcModelCatalog =
serde_json::from_str(json).map_err(|_| "模型目录格式无效".to_string())?;
catalog.validate()?;
Ok(catalog)
}
async fn read_stored_catalog(state: &AppState) -> Result<Option<String>, SpacetimeClientError> {
match state.spacetime_client().read_agc_model_catalog().await {
Ok(json) => Ok(Some(json)),
Err(SpacetimeClientError::Procedure(message))
if message == AGC_MODEL_CATALOG_NOT_INITIALIZED =>
{
Ok(None)
}
Err(error) => Err(error),
}
}
/// 启动期确保目录已初始化:未初始化时从上游模型列表重建,失败只返回错误由调用方记录。
///
/// 目录已可用时不做任何写入;只有缺失、结构与当前定义不符或校验不通过才重建,因此上游模型
/// 变化不会自动覆盖后台维护过的目录。
pub(crate) async fn ensure_agc_model_catalog_initialized(state: &AppState) -> Result<(), String> {
let stored = read_stored_catalog(state)
.await
.map_err(|error| format!("读取 AGC 模型目录失败:{error}"))?;
let revision = match stored.as_deref() {
Some(json) => match parse_catalog(json) {
Ok(_) => return Ok(()),
Err(message) => {
warn!(
error = %message,
"AGC 模型目录内容与当前定义不符,按未初始化处理并从上游重建"
);
stored_catalog_revision(json)
}
},
None => Some(0),
};
let revision = revision.ok_or_else(|| {
"存量 AGC 模型目录缺少可解析的 revision,需要先清理该行再重启".to_string()
})?;
let models = fetch_upstream_model_names(state).await?;
let catalog = AgcModelCatalog::from_upstream_models(models, revision)?;
let payload =
serde_json::to_string(&catalog).map_err(|_| "AGC 模型目录序列化失败".to_string())?;
match state
.spacetime_client()
.save_agc_model_catalog(payload)
.await
{
Ok(saved) => {
let saved: AgcModelCatalog = serde_json::from_str(&saved)
.map_err(|_| "AGC 模型目录写回结果格式无效".to_string())?;
tracing::info!(
revision = saved.revision,
model_count = saved.models.len(),
"已按上游模型列表初始化 AGC 模型目录"
);
Ok(())
}
// 多实例同时启动时只有一个写入成功:接受既有目录,但仍要确认它可用,
// 否则会静默地把「每次启动都冲突、目录一直不可用」变成没有任何线索的黑洞。
Err(SpacetimeClientError::Procedure(message)) if message == AGC_MODEL_CATALOG_CONFLICT => {
let stored = read_stored_catalog(state)
.await
.map_err(|error| format!("写入冲突后重读 AGC 模型目录失败:{error}"))?;
if stored
.as_deref()
.map(parse_catalog)
.is_some_and(|result| result.is_ok())
{
warn!("AGC 模型目录写入冲突:已接受其它实例写入的目录");
Ok(())
} else {
Err("AGC 模型目录写入冲突后仍不可用,需要人工检查该行内容与 revision".to_string())
}
}
Err(error) => Err(format!("写入 AGC 模型目录失败:{error}")),
}
}
fn stored_catalog_revision(json: &str) -> Option<u64> {
serde_json::from_str::<StoredCatalogRevision>(json)
.ok()
.map(|stored| stored.revision)
}
/// 上游在售模型列表:`GET {Router 控制面}/api/pricing?group=taonier` 的 `data[].model_name`。
///
/// 取“该分组可见的在售模型”,而不是管理面模型注册表:注册表里会残留已下线、没有路由绑定的
/// 条目(例如已从上游移除的 `gpt-6-astra`/`gpt-6-luna`),而定价列表就是 AGC 账号实际能调用的集合。
/// 该端点是公开只读接口,不需要管理凭据。
async fn fetch_upstream_model_names(state: &AppState) -> Result<Vec<String>, String> {
crate::external_api_keys::ensure_llm_router_url_allowed(state)?;
let origin =
crate::external_api_keys::router_control_origin(&state.config.llm_router_base_url)?;
let url = format!(
"{origin}/api/pricing?group={}",
crate::external_api_keys::LLM_ROUTER_TOKEN_GROUP
);
let client = reqwest::Client::builder()
.timeout(AGC_MODEL_LIST_REQUEST_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| format!("构建 LLM Router 客户端失败:{error}"))?;
let response = client
.get(url)
.send()
.await
.map_err(|error| format!("请求上游模型列表失败:{error}"))?;
let status = response.status();
if !status.is_success() {
return Err(format!("上游模型列表返回 HTTP {status}"));
}
let bytes = read_bounded_json_body(response).await?;
let payload: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|_| "上游模型列表格式无效".to_string())?;
parse_upstream_model_names(&payload)
}
async fn read_bounded_json_body(mut response: reqwest::Response) -> Result<Vec<u8>, String> {
// 先按 Content-Length 快速拒绝,再流式累加做兜底:不信任上游声明的长度,
// 逐块累计超阈值立即中断,避免 `bytes()` 一次性分配任意大小响应撑爆内存。
if response
.content_length()
.is_some_and(|length| length > AGC_MODEL_LIST_MAX_BYTES as u64)
{
return Err("上游模型列表响应超过大小上限".to_string());
}
let mut bytes = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|error| format!("读取上游模型列表失败:{error}"))?
{
if bytes.len().saturating_add(chunk.len()) > AGC_MODEL_LIST_MAX_BYTES {
return Err("上游模型列表响应超过大小上限".to_string());
}
bytes.extend_from_slice(chunk.as_ref());
}
Ok(bytes)
}
fn parse_upstream_model_names(payload: &serde_json::Value) -> Result<Vec<String>, String> {
let data = payload
.get("data")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "上游模型列表缺少 data 数组".to_string())?;
let models = data
.iter()
.filter_map(|entry| entry.get("model_name").and_then(serde_json::Value::as_str))
.map(str::to_string)
.collect::<Vec<_>>();
if models.iter().all(|model| model.trim().is_empty()) {
return Err("上游模型列表为空".to_string());
}
Ok(models)
}
pub async fn admin_get_agc_models(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -44,6 +226,8 @@ pub async fn admin_save_agc_models(
Extension(_admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminAgcModelCatalog>,
) -> Result<Json<serde_json::Value>, AppError> {
// 目录只来自上游同步:未初始化时后台写入同样失败关闭,避免出现第二条绕过同步的写入口。
load_catalog(&state).await?;
let catalog = AgcModelCatalog {
revision: payload.revision,
default_model_id: payload.default_model_id,
@@ -68,7 +252,7 @@ pub async fn admin_save_agc_models(
.save_agc_model_catalog(payload)
.await
.map_err(|error| {
if matches!(error, spacetime_client::SpacetimeClientError::Procedure(ref message) if message == module_runtime::AGC_MODEL_CATALOG_CONFLICT) {
if matches!(&error, SpacetimeClientError::Procedure(message) if message == AGC_MODEL_CATALOG_CONFLICT) {
AppError::from_status(StatusCode::CONFLICT).with_message("模型目录已被更新,请重新读取")
} else {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("保存模型目录失败,请稍后重试")
@@ -95,3 +279,168 @@ fn catalog_dto(catalog: AgcModelCatalog) -> AdminAgcModelCatalog {
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn upstream_model_names_come_from_pricing_data_array() {
let payload = json!({
"auto_groups": ["default"],
"data": [
{"model_name": "glm-5.3", "model_ratio": 1.0},
{"model_name": "deepseek-flash", "model_ratio": 0.075},
{"model_ratio": 1.0}
]
});
assert_eq!(
parse_upstream_model_names(&payload).unwrap(),
vec!["glm-5.3".to_string(), "deepseek-flash".to_string()]
);
assert_eq!(
parse_upstream_model_names(&json!({"data": []})).unwrap_err(),
"上游模型列表为空"
);
assert_eq!(
parse_upstream_model_names(&json!({"data": [{"model_name": " "}]})).unwrap_err(),
"上游模型列表为空"
);
assert!(parse_upstream_model_names(&json!({"object": "list"})).is_err());
}
#[test]
fn stored_catalog_revision_reads_row_revision() {
assert_eq!(
stored_catalog_revision(
r#"{"revision":4,"defaultModelId":"quality","models":[{"id":"quality","alias":"高质量","modelId":"gpt-6-astra","enabled":true}]}"#
),
Some(4)
);
assert_eq!(stored_catalog_revision("not json"), None);
assert_eq!(stored_catalog_revision(r#"{"models":[]}"#), None);
}
#[test]
fn catalog_parsing_marks_unusable_content_as_uninitialized() {
// 后台保存过的目录结构必须能直接解析。
let catalog = AgcModelCatalog::from_upstream_models(
vec!["deepseek-v4-pro".to_string(), "glm-5.3".to_string()],
4,
)
.unwrap();
assert_eq!(
parse_catalog(&serde_json::to_string(&catalog).unwrap()).unwrap(),
catalog
);
// 结构或内容不合法(例如被外部工具改过)都按未初始化处理,由启动期重新同步。
assert!(
parse_catalog(r#"{"revision":1,"defaultModel":"deepseek-v4-pro","models":[]}"#)
.is_err()
);
assert!(parse_catalog(
r#"{"revision":1,"defaultModelId":"quality","models":[{"id":"quality","alias":"高质量","modelId":"gpt-6-astra","enabled":false}]}"#
)
.is_err());
}
struct MockModelListServer {
base_url: String,
captured: std::sync::Arc<std::sync::Mutex<Option<String>>>,
_handle: std::thread::JoinHandle<()>,
}
fn spawn_mock_model_list_server(status_line: &str, body: &str) -> MockModelListServer {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("mock listener binds");
let address = listener.local_addr().expect("mock address");
let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
let captured_for_thread = std::sync::Arc::clone(&captured);
let response = format!(
"HTTP/1.1 {status_line}\r\ncontent-type: application/json; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("mock accept");
let mut buffer = [0u8; 8192];
let read = stream.read(&mut buffer).unwrap_or_default();
*captured_for_thread.lock().expect("captured lock") =
Some(String::from_utf8_lossy(&buffer[..read]).to_string());
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
});
MockModelListServer {
base_url: format!("http://{address}/v1"),
captured,
_handle: handle,
}
}
fn model_list_state(base_url: &str) -> AppState {
AppState::new(crate::config::AppConfig {
llm_router_base_url: base_url.to_string(),
..crate::config::AppConfig::default()
})
.expect("state should build")
}
#[tokio::test]
async fn fetch_upstream_model_names_reads_group_pricing_without_credentials() {
let server = spawn_mock_model_list_server(
"200 OK",
&json!({"data": [{"model_name": "glm-5.3"}, {"model_name": "deepseek-flash"}]})
.to_string(),
);
let state = model_list_state(&server.base_url);
assert_eq!(
fetch_upstream_model_names(&state).await.unwrap(),
vec!["glm-5.3".to_string(), "deepseek-flash".to_string()]
);
let request = server
.captured
.lock()
.expect("captured lock")
.clone()
.expect("mock server should capture request");
// 控制面路径由 base_url 推导(去掉 /v1),并显式带 AGC 账号所在分组。
assert!(
request.starts_with("GET /api/pricing?group=taonier HTTP/1.1"),
"{request}"
);
// 定价列表是公开只读接口:不得把任何凭据发过去。
assert!(
!request.to_ascii_lowercase().contains("authorization:"),
"{request}"
);
}
#[tokio::test]
async fn fetch_upstream_model_names_fails_closed_when_upstream_unavailable_or_empty() {
let unauthorized = spawn_mock_model_list_server("401 Unauthorized", "{}");
let state = model_list_state(&unauthorized.base_url);
assert_eq!(
fetch_upstream_model_names(&state).await.unwrap_err(),
"上游模型列表返回 HTTP 401 Unauthorized"
);
let empty = spawn_mock_model_list_server("200 OK", &json!({"data": []}).to_string());
let state = model_list_state(&empty.base_url);
assert_eq!(
fetch_upstream_model_names(&state).await.unwrap_err(),
"上游模型列表为空"
);
let failing = spawn_mock_model_list_server("500 Internal Server Error", "{}");
let state = model_list_state(&failing.base_url);
assert_eq!(
fetch_upstream_model_names(&state).await.unwrap_err(),
"上游模型列表返回 HTTP 500 Internal Server Error"
);
}
}

Some files were not shown because too many files have changed in this diff Show More