Merge remote-tracking branch 'origin/master' into fix/log-cmd-refuse-reason
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m14s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m47s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m14s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m47s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
This commit is contained in:
@@ -56,7 +56,9 @@ export default defineConfig(({ command, mode }) => {
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
chunkSizeWarningLimit: 600,
|
||||
// 中文注释:后台单页 chunk 已增长到 600.23 kB;沿用 2026-05-01 把 500 提到 600 的同一处理口径
|
||||
// 提到 700,先让直推 master 的构建门禁恢复可过,真正的按路由拆分另行排期。
|
||||
chunkSizeWarningLimit: 700,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -98,10 +98,13 @@ const appInvokeSources = readSourceFiles(
|
||||
new URL('../src/', import.meta.url),
|
||||
new Set(['.ts', '.tsx']),
|
||||
);
|
||||
const tauriHandlerSource = fs.readFileSync(
|
||||
// 生产接线在 desktop,受测的启动诊断实现仍在 main;两者共同构成启动检查范围。
|
||||
const tauriHandlerSource = [
|
||||
new URL('../src-tauri/src/main.rs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
new URL('../src-tauri/src/desktop.rs', import.meta.url),
|
||||
]
|
||||
.map((source) => fs.readFileSync(source, 'utf8'))
|
||||
.join('\n');
|
||||
const tauriRustSource = readSourceTree(
|
||||
new URL('../src-tauri/src/', import.meta.url),
|
||||
'.rs',
|
||||
|
||||
@@ -54,16 +54,19 @@ pub(crate) use claude_code_cli::{
|
||||
direct_game_creator_claude_code_home_chat, game_creator_claude_code_cli_route_error,
|
||||
game_creator_claude_code_cli_version_identity,
|
||||
};
|
||||
pub(crate) use codex_app_server::direct_game_creator_codex_chat_at;
|
||||
use codex_app_server::*;
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use codex_app_server::{
|
||||
cancel_direct_codex_turn_at, direct_game_creator_codex_chat_at,
|
||||
direct_game_creator_home_codex_chat, thread_id_for_project, DirectTurnCancelView,
|
||||
cancel_direct_codex_turn_at, direct_game_creator_home_codex_chat, thread_id_for_project,
|
||||
DirectTurnCancelView,
|
||||
};
|
||||
use codex_cli::*;
|
||||
pub(crate) use codex_cli::{
|
||||
game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity,
|
||||
};
|
||||
pub(crate) use codex_provider_proxy::*;
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use design_runtime::*;
|
||||
pub(crate) use direct_codex_attachments::*;
|
||||
pub(crate) use direct_codex_user_item::*;
|
||||
|
||||
@@ -2553,34 +2553,15 @@ impl CodexAppServerConnection {
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn spawn(
|
||||
llm: &GameCreatorLlmConfig,
|
||||
credential: &CodexAppServerCredential,
|
||||
) -> Result<Self, platform_llm::LlmError> {
|
||||
let executable = game_creator_codex_cli_executable_path()
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||
Self::spawn_with_executable_and_credential(llm, credential, executable.as_os_str()).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
async fn spawn_with_executable(
|
||||
llm: &GameCreatorLlmConfig,
|
||||
executable: &std::ffi::OsStr,
|
||||
) -> Result<Self, platform_llm::LlmError> {
|
||||
let credential = resolve_game_creator_codex_app_server_credential(llm)?;
|
||||
Self::spawn_with_executable_and_credential(llm, &credential, executable).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn spawn_with_executable_and_credential(
|
||||
llm: &GameCreatorLlmConfig,
|
||||
credential: &CodexAppServerCredential,
|
||||
executable: &std::ffi::OsStr,
|
||||
) -> Result<Self, platform_llm::LlmError> {
|
||||
Self::spawn_with_executable_and_credential_at_workspace(
|
||||
llm,
|
||||
credential,
|
||||
&credential,
|
||||
executable,
|
||||
None,
|
||||
CodexAppServerWorkspaceMode::ToolHost,
|
||||
@@ -2588,7 +2569,7 @@ impl CodexAppServerConnection {
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
async fn spawn_with_executable_and_credential_at_workspace(
|
||||
llm: &GameCreatorLlmConfig,
|
||||
credential: &CodexAppServerCredential,
|
||||
@@ -5466,6 +5447,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
/// fresh isolated read-only workspace and a stable in-process thread so a
|
||||
/// normal conversation can continue without creating a project, assets, a
|
||||
/// preview, or a version record.
|
||||
#[cfg(not(test))]
|
||||
pub(crate) async fn direct_game_creator_home_codex_chat(
|
||||
system_prompt: String,
|
||||
user_prompt: String,
|
||||
@@ -5677,6 +5659,104 @@ mod tests {
|
||||
assert!(table.select(&key, None).is_err());
|
||||
}
|
||||
|
||||
fn exited_direct_turn_cancellation() -> Arc<CodexTurnStartCancellation> {
|
||||
Arc::new(CodexTurnStartCancellation {
|
||||
inner: Weak::new(),
|
||||
thread_id: String::new(),
|
||||
turn_id: std::sync::Mutex::new(None),
|
||||
cancelled: AtomicBool::new(false),
|
||||
interrupt_sent: AtomicBool::new(false),
|
||||
execution: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_direct_codex_turn_rejects_another_turn_without_releasing_it() {
|
||||
let root = tempfile::tempdir().expect("direct turn project");
|
||||
let thread_id = thread_id_for_project(root.path());
|
||||
let invocation = TurnReservation::accept_for_test(&thread_id, "client-turn-current");
|
||||
let cancellation = exited_direct_turn_cancellation();
|
||||
let key = direct_codex_active_turn_key(root.path());
|
||||
let _registered = register_active_direct_codex_turn(
|
||||
key.clone(),
|
||||
"client-turn-current",
|
||||
Arc::clone(&cancellation),
|
||||
);
|
||||
let subscription = crate::agent::subscribe_thread(&thread_id);
|
||||
let _ = crate::agent::consume_thread(&subscription.subscription_id)
|
||||
.expect("drain turn start event");
|
||||
|
||||
let error = cancel_direct_codex_turn_at(root.path(), Some("client-turn-other"))
|
||||
.expect_err("another turn must not be interrupted or released");
|
||||
assert_eq!(error, DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE);
|
||||
assert!(!cancellation.cancelled.load(Ordering::Acquire));
|
||||
assert_eq!(
|
||||
direct_codex_active_turns()
|
||||
.lock()
|
||||
.expect("active turn table")
|
||||
.registered_client_turn_id(&key),
|
||||
Some("client-turn-current")
|
||||
);
|
||||
assert_eq!(
|
||||
crate::agent::read_turn_identity(&thread_id)
|
||||
.expect("original turn remains active")
|
||||
.client_turn_id,
|
||||
"client-turn-current"
|
||||
);
|
||||
assert!(
|
||||
crate::agent::consume_thread(&subscription.subscription_id)
|
||||
.expect("consume turn events")
|
||||
.events
|
||||
.is_empty(),
|
||||
"wrong-turn rejection must not publish a terminal event"
|
||||
);
|
||||
drop(invocation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_direct_codex_turn_releases_exited_executor_and_completes_original_turn() {
|
||||
let root = tempfile::tempdir().expect("direct turn project");
|
||||
let thread_id = thread_id_for_project(root.path());
|
||||
let invocation = TurnReservation::accept_for_test(&thread_id, "client-turn-exited");
|
||||
let _registered = register_active_direct_codex_turn(
|
||||
direct_codex_active_turn_key(root.path()),
|
||||
"client-turn-exited",
|
||||
exited_direct_turn_cancellation(),
|
||||
);
|
||||
let subscription = crate::agent::subscribe_thread(&thread_id);
|
||||
let _ = crate::agent::consume_thread(&subscription.subscription_id)
|
||||
.expect("drain turn start event");
|
||||
|
||||
let view = cancel_direct_codex_turn_at(root.path(), Some("client-turn-exited"))
|
||||
.expect("exited executor releases the residual invocation");
|
||||
assert_eq!(view.outcome, DIRECT_TURN_CANCEL_OUTCOME_RELEASED);
|
||||
assert_eq!(view.client_turn_id, "client-turn-exited");
|
||||
assert!(view.message.contains("执行进程已退出"));
|
||||
assert_eq!(crate::agent::read_turn_identity(&thread_id), None);
|
||||
let events = crate::agent::consume_thread(&subscription.subscription_id)
|
||||
.expect("consume terminal event")
|
||||
.events;
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[ThreadEvent::TurnCompleted {
|
||||
status,
|
||||
failure: None,
|
||||
at: Some(_),
|
||||
user_item_id: Some(user_item_id),
|
||||
}] if status == "aborted" && user_item_id == "direct-codex:client-turn-exited:user"
|
||||
));
|
||||
|
||||
let next = TurnReservation::accept_for_test(&thread_id, "client-turn-next");
|
||||
drop(invocation);
|
||||
assert_eq!(
|
||||
crate::agent::read_turn_identity(&thread_id)
|
||||
.expect("old guard must not clear next turn")
|
||||
.client_turn_id,
|
||||
"client-turn-next"
|
||||
);
|
||||
drop(next);
|
||||
}
|
||||
|
||||
/// 终止路径会从同步命令线程和 `Drop` 里派发 app-server 任务:那些线程没有 tokio
|
||||
/// runtime 上下文。`tokio::spawn` 在那里 panic,panic 跨不过 IPC 回调边界就把整个
|
||||
/// 进程 abort(0xC0000409,"点终止就闪退")。这条用例把派发入口钉在没有 runtime
|
||||
@@ -7576,6 +7656,8 @@ while IFS= read -r line; do :; done
|
||||
.map(|argument| argument.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(home_arguments.contains("--disable\nshell_tool"));
|
||||
assert!(home_arguments.contains("--disable\nunified_exec"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
#[cfg(not(test))]
|
||||
use tauri::Emitter;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -1398,12 +1399,23 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) async fn hydrate_design_agent_session(
|
||||
app: tauri::AppHandle,
|
||||
project_path: String,
|
||||
) -> Result<Option<DesignView>, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
hydrate_design_agent_session_at(root, move || {
|
||||
DesignResources::new(resolve_design_resources_root(&app)?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn hydrate_design_agent_session_at(
|
||||
root: &Path,
|
||||
load_resources: impl FnOnce() -> Result<DesignResources, String> + Send,
|
||||
) -> Result<Option<DesignView>, String> {
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
if read_design_runtime_mode(root)?
|
||||
.as_ref()
|
||||
@@ -1432,7 +1444,7 @@ pub(crate) async fn hydrate_design_agent_session(
|
||||
return Ok(Some(design_view(&session, true)));
|
||||
};
|
||||
if design_session_has_uncertain_batch(&session) {
|
||||
let resources = DesignResources::new(resolve_design_resources_root(&app)?)?;
|
||||
let resources = load_resources()?;
|
||||
let view = recover_uncertain_design_batch(root, &resources, session, active).await?;
|
||||
return Ok(Some(view));
|
||||
}
|
||||
@@ -1476,9 +1488,14 @@ fn quarantine_corrupted_design_session(root: &Path) -> Result<(), String> {
|
||||
std::fs::rename(&path, &backup).map_err(|error| format!("隔离损坏的策划会话失败:{error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) fn reset_design_agent_session(project_path: String) -> Result<(), String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
reset_design_agent_session_at(root)
|
||||
}
|
||||
|
||||
fn reset_design_agent_session_at(root: &Path) -> Result<(), String> {
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
design_project_id(root)?;
|
||||
let path = crate::agent::runtime_protocol::design_session_path(root);
|
||||
@@ -1488,6 +1505,7 @@ pub(crate) fn reset_design_agent_session(project_path: String) -> Result<(), Str
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_design_agent_runtime_mode(
|
||||
project_path: String,
|
||||
@@ -1515,8 +1533,13 @@ fn design_debug_enabled() -> bool {
|
||||
== Some("1")
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) fn is_design_agent_debug_enabled() -> bool {
|
||||
design_debug_command_enabled()
|
||||
}
|
||||
|
||||
fn design_debug_command_enabled() -> bool {
|
||||
cfg!(debug_assertions) && design_debug_enabled()
|
||||
}
|
||||
|
||||
@@ -1540,16 +1563,27 @@ pub(crate) fn set_design_agent_runtime_mode(
|
||||
write_design_runtime_mode(root, active_runtime.trim())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) fn debug_fast_forward_design_session(
|
||||
app: tauri::AppHandle,
|
||||
project_path: String,
|
||||
target_phase: String,
|
||||
) -> Result<DesignRuntimeMode, String> {
|
||||
if !is_design_agent_debug_enabled() {
|
||||
debug_fast_forward_design_session_at(Path::new(project_path.trim()), &target_phase, |event| {
|
||||
app.emit("design-agent-update", event)
|
||||
.map_err(|error| format!("刷新策划调试状态失败:{error}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn debug_fast_forward_design_session_at(
|
||||
root: &Path,
|
||||
target_phase: &str,
|
||||
emit: impl FnOnce(DesignEvent) -> Result<(), String>,
|
||||
) -> Result<DesignRuntimeMode, String> {
|
||||
if !design_debug_command_enabled() {
|
||||
return Err("策划 Agent 快速推进仅可用于 Debug 构建".to_string());
|
||||
}
|
||||
let root = Path::new(project_path.trim());
|
||||
let project_id = design_project_id(root)?;
|
||||
let target_index = design_phase_index(target_phase.trim())?;
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
@@ -1597,21 +1631,18 @@ pub(crate) fn debug_fast_forward_design_session(
|
||||
session.updated_at = unix_timestamp();
|
||||
write_design_session(root, &session)?;
|
||||
let mode = write_design_runtime_mode(root, "design")?;
|
||||
app.emit(
|
||||
"design-agent-update",
|
||||
design_event(
|
||||
root,
|
||||
"debug-fast-forward",
|
||||
"state",
|
||||
None,
|
||||
None,
|
||||
Some(design_view(&session, false)),
|
||||
),
|
||||
)
|
||||
.map_err(|error| format!("刷新策划调试状态失败:{error}"))?;
|
||||
emit(design_event(
|
||||
root,
|
||||
"debug-fast-forward",
|
||||
"state",
|
||||
None,
|
||||
None,
|
||||
Some(design_view(&session, false)),
|
||||
))?;
|
||||
Ok(mode)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) async fn continue_design_agent_session(
|
||||
app: tauri::AppHandle,
|
||||
@@ -1636,6 +1667,7 @@ pub(crate) async fn continue_design_agent_session(
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) async fn decide_design_phase(
|
||||
app: tauri::AppHandle,
|
||||
@@ -1662,6 +1694,7 @@ pub(crate) async fn decide_design_phase(
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) fn list_design_workspace(
|
||||
project_path: String,
|
||||
@@ -1672,6 +1705,7 @@ pub(crate) fn list_design_workspace(
|
||||
list_design_workspace_files(root)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_design_workspace_file(
|
||||
project_path: String,
|
||||
@@ -1685,7 +1719,6 @@ pub(crate) fn read_design_workspace_file(
|
||||
|
||||
#[cfg(test)]
|
||||
mod fake_provider {
|
||||
use super::*;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
@@ -3236,6 +3269,105 @@ mod tests {
|
||||
assert!(next.session.last_error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn hydrate_quarantines_a_corrupted_session_without_loading_resources() {
|
||||
let (_temp, root, _resources) = init_design_project();
|
||||
let path = crate::agent::runtime_protocol::design_session_path(&root);
|
||||
fs::create_dir_all(path.parent().expect("session directory")).expect("create session dir");
|
||||
fs::write(&path, b"{broken").expect("write malformed session");
|
||||
|
||||
let view = hydrate_design_agent_session_at(&root, || -> Result<DesignResources, String> {
|
||||
panic!("corrupted session must not load resources")
|
||||
})
|
||||
.await
|
||||
.expect("quarantine malformed session");
|
||||
assert!(view.is_none());
|
||||
assert!(!path.exists());
|
||||
let backups = fs::read_dir(path.parent().expect("session directory"))
|
||||
.expect("list session directory")
|
||||
.map(|entry| entry.expect("session entry").path())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.is_some_and(|name| name.to_string_lossy().starts_with("session.corrupted."))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(backups.len(), 1);
|
||||
assert_eq!(
|
||||
fs::read(&backups[0]).expect("read quarantined session"),
|
||||
b"{broken"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_archives_the_existing_design_session() {
|
||||
let (_temp, root, _resources) = init_design_project();
|
||||
let path = crate::agent::runtime_protocol::design_session_path(&root);
|
||||
write_design_session(&root, &new_design_session("design-fake", ""))
|
||||
.expect("write design session");
|
||||
|
||||
reset_design_agent_session_at(&root).expect("reset design session");
|
||||
assert!(!path.exists());
|
||||
let backups = fs::read_dir(path.parent().expect("session directory"))
|
||||
.expect("list session directory")
|
||||
.map(|entry| entry.expect("session entry").path())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.is_some_and(|name| name.to_string_lossy().starts_with("session.corrupted."))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(backups.len(), 1);
|
||||
reset_design_agent_session_at(&root).expect("repeat reset without active session");
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[test]
|
||||
fn debug_fast_forward_checks_the_gate_and_persists_before_event_delivery() {
|
||||
let (_temp, root, _resources) = init_design_project();
|
||||
let _debug_env = enable_design_debug_for_test();
|
||||
std::env::remove_var("GENARRATIVE_AGC_DESIGN_DEBUG");
|
||||
let error = debug_fast_forward_design_session_at(&root, "architecture", |_| {
|
||||
panic!("debug gate must reject before emitting")
|
||||
})
|
||||
.expect_err("debug gate should be closed");
|
||||
assert!(error.contains("Debug 构建"));
|
||||
assert!(read_design_session(&root).expect("read session").is_none());
|
||||
|
||||
std::env::set_var("GENARRATIVE_AGC_DESIGN_DEBUG", "1");
|
||||
let existing = root.join("design_artifacts/project/00_concept/design.md");
|
||||
fs::create_dir_all(existing.parent().expect("artifact directory"))
|
||||
.expect("create artifact directory");
|
||||
fs::write(&existing, "已有真实内容").expect("write existing artifact");
|
||||
let error = debug_fast_forward_design_session_at(&root, "architecture", |event| {
|
||||
assert_eq!(event.kind, "state");
|
||||
assert_eq!(event.client_turn_id, "debug-fast-forward");
|
||||
Err("event delivery failed".to_string())
|
||||
})
|
||||
.expect_err("event failure should be reported");
|
||||
assert_eq!(error, "event delivery failed");
|
||||
assert_eq!(
|
||||
fs::read_to_string(existing).expect("read existing artifact"),
|
||||
"已有真实内容"
|
||||
);
|
||||
assert!(root
|
||||
.join("design_artifacts/project/02_architecture/design.md")
|
||||
.is_file());
|
||||
let session = read_design_session(&root)
|
||||
.expect("read fast-forwarded session")
|
||||
.expect("fast-forwarded session");
|
||||
assert_eq!(session.current_phase, "architecture");
|
||||
assert_eq!(session.approved_phases, vec!["concept", "top_design"]);
|
||||
assert!(session.pending_batch.is_none() && session.turn.is_none());
|
||||
assert_eq!(
|
||||
read_design_runtime_mode(&root)
|
||||
.expect("read runtime mode")
|
||||
.expect("runtime mode")
|
||||
.active_runtime,
|
||||
"design"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() {
|
||||
let (_temp, root, resources) = init_design_project();
|
||||
@@ -3291,16 +3423,10 @@ mod tests {
|
||||
))],
|
||||
0,
|
||||
);
|
||||
let view = recover_uncertain_design_batch(&root, &resources, session, {
|
||||
try_open_game_creator_agent_runtime_task_lock_file(
|
||||
&root,
|
||||
".agent/design-agent/active.lock",
|
||||
)
|
||||
.expect("open active lock")
|
||||
.expect("active lock is free")
|
||||
})
|
||||
.await
|
||||
.expect("recover uncertain batch");
|
||||
let view = hydrate_design_agent_session_at(&root, move || Ok(resources))
|
||||
.await
|
||||
.expect("hydrate uncertain batch")
|
||||
.expect("restored session");
|
||||
|
||||
assert!(!view.running);
|
||||
assert!(view.session.last_error.is_none());
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(not(test))]
|
||||
use tauri::Manager;
|
||||
|
||||
const DESIGN_WORKSPACE_ROOT: &str = "design_artifacts";
|
||||
@@ -168,6 +169,7 @@ impl DesignResources {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn resolve_design_resources_root(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let mut candidates = vec![PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent")];
|
||||
if let Ok(dir) = app.path().resource_dir() {
|
||||
@@ -539,6 +541,7 @@ pub(crate) fn read_design_workspace_file_at(root: &Path, path: &str) -> Result<S
|
||||
fs::read_to_string(&target).map_err(|error| format!("读取失败:{error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) fn import_design_workspace_file(
|
||||
app: tauri::AppHandle,
|
||||
|
||||
@@ -737,6 +737,7 @@ pub(crate) fn read_direct_project_history_items_slice_at(
|
||||
/// 最新一条可显示条目的 itemId:首屏历史锚点。
|
||||
///
|
||||
/// 与"最近一屏"共用尾部回扫,读一行就能返回,不回读整份历史。
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result<Option<String>, String> {
|
||||
Ok(read_direct_project_history_items_slice_at(root, DirectProjectHistoryAnchor::Newest, 1)?.3)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
mod user_input;
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use user_input::enqueue_direct_codex_turn;
|
||||
#[cfg(test)]
|
||||
pub(crate) use user_input::normalize_direct_client_turn_id;
|
||||
|
||||
@@ -43,6 +43,7 @@ pub(crate) fn normalize_direct_client_turn_id(
|
||||
/// 终端上)已经退役,不要再为"手工跑一轮"新增第二条直接起回合的路径。
|
||||
///
|
||||
/// 设计见 `docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) async fn enqueue_direct_codex_turn(
|
||||
project_path: String,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use super::*;
|
||||
use axum::extract::{DefaultBodyLimit, State as AxumState};
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::extract::State as AxumState;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
@@ -55,6 +57,7 @@ pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool {
|
||||
args == [DIRECT_TOOLS_MCP_MODE_FLAG]
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option<i32> {
|
||||
if !direct_tools_mcp_mode_requested(args) {
|
||||
return None;
|
||||
@@ -1984,6 +1987,7 @@ fn write_direct_tools_mcp_response(
|
||||
.map_err(|_| "写入 MCP 响应失败".to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn read_direct_tools_mcp_requests(
|
||||
mut reader: impl BufRead,
|
||||
requests: tokio::sync::mpsc::Sender<Result<Vec<u8>, String>>,
|
||||
@@ -2062,6 +2066,7 @@ where
|
||||
terminal_error.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
async fn run_direct_tools_mcp_stdio() -> Result<(), String> {
|
||||
let root = validate_direct_tools_project_root(
|
||||
&std::env::current_dir().map_err(|_| "读取 MCP 工作目录失败".to_string())?,
|
||||
@@ -2177,6 +2182,7 @@ pub(crate) fn stop_external_mcp_loopback() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) async fn start_game_creator_external_mcp(project_path: String) -> Result<Value, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
@@ -2185,6 +2191,7 @@ pub(crate) async fn start_game_creator_external_mcp(project_path: String) -> Res
|
||||
Ok(json!({ "url": url, "token": token, "transport": "streamable-http" }))
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> {
|
||||
stop_external_mcp_loopback();
|
||||
|
||||
@@ -48,7 +48,6 @@ pub(in crate::agent) use external_generation_state::{
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use external_generation_state::{
|
||||
platform_art_generation_external_configuration_fingerprint,
|
||||
platform_art_generation_legacy_external_configuration_fingerprint,
|
||||
setup_platform_art_generation_runtime_accepted_for_recovery_test,
|
||||
write_platform_art_generation_runtime_accepted_for_test,
|
||||
|
||||
@@ -6,6 +6,7 @@ pub(crate) struct AgentProgressEmitter<'a> {
|
||||
}
|
||||
|
||||
impl<'a> AgentProgressEmitter<'a> {
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn new(app: &'a tauri::AppHandle, project_path: &str) -> Self {
|
||||
Self {
|
||||
app,
|
||||
|
||||
+8
-47
@@ -817,24 +817,17 @@ mod tests {
|
||||
use super::{
|
||||
acquire_game_creator_agent_provider_plan_project_write_lock_with_wait,
|
||||
agent_runtime_root_source_at, bind_game_creator_agent_runtime_run_profile_at,
|
||||
build_game_creator_agent_background_final_reply_request,
|
||||
build_game_creator_agent_background_tool_plan_request_locked,
|
||||
game_creator_agent_context_preload_notice,
|
||||
game_creator_agent_runtime_run_profile_binding_path,
|
||||
game_creator_project_supervisor_chat_system_prompt, init_local_game_project_at,
|
||||
new_game_creation_app_seed_tasks, provider_command_exec_contract,
|
||||
provider_command_start_contract,
|
||||
game_creator_agent_context_preload_notice, init_local_game_project_at,
|
||||
new_game_creation_app_seed_tasks,
|
||||
render_relaxed_autonomous_manifest_ready_task_background_prompt,
|
||||
required_runtime_prompt_section, start_game_creator_agent_runtime_task_at,
|
||||
AgentRuntimeGoalContractAcceptanceNodeDraft, AgentRuntimeGoalContractDraft,
|
||||
AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan,
|
||||
start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft,
|
||||
AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation,
|
||||
AgentRuntimeToolPlanRequestSnapshot, GameCreatorLlmConfig,
|
||||
AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT,
|
||||
AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
|
||||
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_RUN_PROFILE_STANDARD,
|
||||
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
|
||||
AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION,
|
||||
AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT, AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
|
||||
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
};
|
||||
|
||||
fn build_game_creator_agent_background_tool_plan_request_for_test(
|
||||
@@ -871,24 +864,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn native_input_required_fields(
|
||||
request: &platform_llm::LlmRunRequest,
|
||||
tool: &str,
|
||||
) -> Vec<String> {
|
||||
let function_name = crate::agent_native_tools::native_runtime_function_name(tool)
|
||||
.expect("native runtime function name");
|
||||
request
|
||||
.function_tools
|
||||
.iter()
|
||||
.find(|function| function.name == function_name)
|
||||
.and_then(|function| function.parameters.pointer("/properties/input/required"))
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.expect("native input required fields")
|
||||
.iter()
|
||||
.map(|field| field.as_str().expect("required field string").to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relaxed_request_keeps_general_catalog_after_plan_rejection() {
|
||||
let directory = crate::tests::canonical_test_tempdir("provider-plan-rejection-repair-");
|
||||
@@ -1437,20 +1412,6 @@ mod tests {
|
||||
assert!(!prompt.contains("本轮唯一可用工具是 agent.goal_contract"));
|
||||
}
|
||||
|
||||
fn completion_blocker_protocol_requires_tool_repair_before_response() {
|
||||
let protocol = AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL;
|
||||
|
||||
assert!(protocol.contains("最新 observation"));
|
||||
assert!(protocol.contains("runtime.autonomous_completion"));
|
||||
assert!(protocol.contains("status 为 blocked"));
|
||||
assert!(protocol.contains("才能调用 respond_to_user"));
|
||||
assert!(!protocol.contains("legacy response"));
|
||||
assert!(protocol.contains("observation.detail 的 nextRequiredAction"));
|
||||
assert!(protocol.contains("读取、修复和验证工具"));
|
||||
assert!(protocol.contains("完成要求的动作、取得后续可信 observation"));
|
||||
assert!(protocol.contains("通过完成门禁后"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supervisor_request_snapshot_preserves_prompt_visible_running_sibling_after_manifest_failure()
|
||||
{
|
||||
|
||||
@@ -297,18 +297,18 @@ pub(crate) use entrypoints::{
|
||||
generate_local_game_draft_at, read_game_creator_agent_runtime_at,
|
||||
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
|
||||
register_game_creator_manifest_invalidation_event_sink,
|
||||
validate_game_creator_manifest_invalidation_event_sink,
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use entrypoints::{
|
||||
set_game_creator_agent_runtime_update_app_handle,
|
||||
start_game_creator_manifest_invalidation_event_sink,
|
||||
validate_game_creator_manifest_invalidation_event_sink,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at;
|
||||
pub(crate) use finalization::AgentRuntimePendingActionResume;
|
||||
#[cfg(test)]
|
||||
pub(crate) use interaction::{
|
||||
acquire_game_creator_agent_runtime_user_input_answer_locks_for_test,
|
||||
agent_runtime_tool_requires_repository_context_fingerprint_gate,
|
||||
};
|
||||
pub(crate) use interaction::agent_runtime_tool_requires_repository_context_fingerprint_gate;
|
||||
pub(crate) use interaction::{
|
||||
answer_game_creator_agent_runtime_user_input_at, confirm_game_creator_agent_runtime_task_at,
|
||||
pending_repository_context_drift_observation, reject_game_creator_agent_runtime_task_at,
|
||||
|
||||
@@ -17,6 +17,7 @@ fn lock_game_creator_manifest_invalidation_event_sinks(
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHandle) {
|
||||
let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app);
|
||||
}
|
||||
@@ -201,6 +202,7 @@ impl DirectGameCreatorTurnUpdateEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn start_game_creator_manifest_invalidation_event_sink(
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<GameCreatorManifestInvalidationEventSink, String> {
|
||||
|
||||
@@ -477,20 +477,6 @@ fn resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks(
|
||||
Ok((None, runtime_lock, resolved))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn acquire_game_creator_agent_runtime_user_input_answer_locks_for_test(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
action_id: &str,
|
||||
) -> Result<(Option<ProjectWriteLock>, AgentRuntimeTaskLock), String> {
|
||||
let (project_lock, runtime_lock, _) =
|
||||
resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks(
|
||||
root, agent_id, run_id, action_id,
|
||||
)?;
|
||||
Ok((project_lock, runtime_lock))
|
||||
}
|
||||
|
||||
pub(crate) fn answer_game_creator_agent_runtime_user_input_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
|
||||
@@ -89,6 +89,8 @@ pub(crate) use run_configuration::{
|
||||
game_creator_agent_runtime_project_revision_path,
|
||||
game_creator_agent_runtime_run_profile_binding_path,
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use steering::game_creator_agent_runtime_provider_request_count_for_roots;
|
||||
#[cfg(test)]
|
||||
pub(crate) use steering::{
|
||||
acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait,
|
||||
@@ -96,7 +98,6 @@ pub(crate) use steering::{
|
||||
};
|
||||
pub(crate) use steering::{
|
||||
consume_game_creator_agent_runtime_steers,
|
||||
game_creator_agent_runtime_provider_request_count_for_roots,
|
||||
interrupt_game_creator_agent_runtime_provider_request_at,
|
||||
interrupt_game_creator_agent_runtime_provider_requests_for_roots,
|
||||
register_game_creator_agent_runtime_provider_request,
|
||||
|
||||
-5
@@ -11943,11 +11943,6 @@ impl ExternalGameplayJavascript {
|
||||
.iter()
|
||||
.any(|unit| unit.to_ascii_lowercase().contains(&marker))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(in crate::agent) fn module_units(&self) -> &[String] {
|
||||
&self.module_units
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_javascript_module_analysis_sources(
|
||||
|
||||
@@ -44,9 +44,7 @@ pub(in crate::agent) use ui_design_doc::*;
|
||||
#[cfg(test)]
|
||||
pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked;
|
||||
#[cfg(test)]
|
||||
pub(crate) use delivery::{
|
||||
build_static_delegate_result_for_child_at, wake_waiting_static_delegate_parent_run_for_test_at,
|
||||
};
|
||||
pub(crate) use delivery::build_static_delegate_result_for_child_at;
|
||||
#[cfg(test)]
|
||||
pub(crate) use isolated_joins::render_isolated_join_status_batch_with_limit;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -408,14 +408,6 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn wake_waiting_static_delegate_parent_run_for_test_at(
|
||||
root: &Path,
|
||||
parent_task: &AgentRuntimeTaskRecord,
|
||||
) -> Result<bool, String> {
|
||||
wake_waiting_static_delegate_parent_run_at(root, parent_task)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn wake_waiting_autonomous_manifest_parent_run_at(
|
||||
root: &Path,
|
||||
parent_task: &AgentRuntimeTaskRecord,
|
||||
|
||||
@@ -13,8 +13,6 @@ use crate::agent::{
|
||||
AgentRuntimeToolPlan, AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT,
|
||||
AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PLAN_STEP_LIMIT,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID;
|
||||
|
||||
pub(crate) const AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME: &str = "update_agent_plan";
|
||||
pub(crate) const AGENT_RUNTIME_RESPOND_FUNCTION_NAME: &str = "respond_to_user";
|
||||
|
||||
@@ -114,19 +114,10 @@ pub(crate) fn created(writer: Option<&AnalyticsWriter>, root: &Path, project_id:
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn retry_pending(writer: &AnalyticsWriter) {
|
||||
let projects: Vec<_> = pending()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.projects
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
for (root, project_id) in projects {
|
||||
// 投递失败不移除资格;后续真实受理仍可重试。
|
||||
writer.try_goal(Request::Created { root, project_id });
|
||||
}
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
mod desktop;
|
||||
#[cfg(not(test))]
|
||||
pub(super) use desktop::retry_pending;
|
||||
|
||||
// 调用方已取得项目独立埋点锁;不得在文件 I/O 期间持有 pending 内存锁。
|
||||
fn initialize_pending(root: &Path, project_id: &str, path: &Path) -> Result<(), String> {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
use super::{pending, AnalyticsWriter, Request};
|
||||
|
||||
pub(crate) fn retry_pending(writer: &AnalyticsWriter) {
|
||||
let projects: Vec<_> = pending()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.projects
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
for (root, project_id) in projects {
|
||||
// 投递失败不移除资格;后续真实受理仍可重试。
|
||||
writer.try_goal(Request::Created { root, project_id });
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,15 @@ use super::contract::*;
|
||||
use super::session::{LifecycleState, SessionRecord};
|
||||
use super::store::AnalyticsWriter;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Instant;
|
||||
use tauri::Manager;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(not(test))]
|
||||
mod desktop;
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use desktop::*;
|
||||
|
||||
struct OpenProject {
|
||||
id: String,
|
||||
path: String,
|
||||
@@ -44,8 +47,6 @@ struct Service {
|
||||
}
|
||||
|
||||
static GUI: OnceLock<Service> = OnceLock::new();
|
||||
// 仅串行化发布服务与退出;后台等待认证状态时不占此锁。
|
||||
static EXITING: Mutex<bool> = Mutex::new(false);
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct LifecycleFixture(GuiState);
|
||||
@@ -373,50 +374,6 @@ fn elapsed_ms(start: Instant) -> u64 {
|
||||
start.elapsed().as_millis().min(9_007_199_254_740_991) as u64
|
||||
}
|
||||
|
||||
pub(crate) fn initialize(app: tauri::AppHandle, config_dir: PathBuf, version: String) {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
crate::platform_session::initialize_analytics_identity(|route, sequence| {
|
||||
let Ok(exiting) = EXITING.lock() else {
|
||||
return;
|
||||
};
|
||||
if !*exiting {
|
||||
initialize_with_identity(config_dir, version, route, sequence);
|
||||
}
|
||||
});
|
||||
if let Some(gui) = GUI.get() {
|
||||
super::goal::retry_pending(&gui.writer);
|
||||
}
|
||||
let handle = app.clone();
|
||||
let _ = app.run_on_main_thread(move || observe_windows(&handle, None, false, true));
|
||||
});
|
||||
}
|
||||
|
||||
fn initialize_with_identity(config_dir: PathBuf, version: String, route: Route, sequence: u64) {
|
||||
if GUI.get().is_some() {
|
||||
return;
|
||||
}
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
let writer = AnalyticsWriter::start(config_dir.clone(), session_id.clone());
|
||||
let context = Context {
|
||||
route,
|
||||
editor_session_id: session_id.clone(),
|
||||
client_version: version.clone(),
|
||||
};
|
||||
let state = GuiState::new(context, sequence, writer.clone());
|
||||
if GUI
|
||||
.set(Service {
|
||||
session_id,
|
||||
version,
|
||||
writer,
|
||||
state: Mutex::new(state),
|
||||
})
|
||||
.is_ok()
|
||||
{
|
||||
with_state(|state| state.start());
|
||||
super::upload::start(config_dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn with_state<T>(f: impl FnOnce(&mut GuiState) -> T) -> Option<T> {
|
||||
let gui = GUI.get()?;
|
||||
// 临界区只更新内存并 try_send,不做磁盘/网络操作,也不重新取得业务锁。
|
||||
@@ -480,164 +437,6 @@ pub(crate) fn created(
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn record_analytics_project_open(
|
||||
window: tauri::Window,
|
||||
context: Context,
|
||||
project_path: String,
|
||||
operation_id: String,
|
||||
open_source: OpenSource,
|
||||
event_time: String,
|
||||
) {
|
||||
if !valid_context(&context)
|
||||
|| Uuid::parse_str(&operation_id).is_err()
|
||||
|| project_path.len() > 32768
|
||||
|| !valid_time(&event_time)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let label = window.label().to_string();
|
||||
if with_state(|state| {
|
||||
state.reserve_open(&label, &operation_id, &project_path, &event_time);
|
||||
})
|
||||
.is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 目录/manifest 读取不占用 GUI 线程,也不会把读失败返回给业务操作。
|
||||
let _ = tauri::async_runtime::spawn_blocking(move || {
|
||||
let Ok(root) = crate::validated_local_project_directory_path(&project_path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else {
|
||||
return;
|
||||
};
|
||||
let project = OpenProject {
|
||||
id: manifest.project_id,
|
||||
path: project_path,
|
||||
operation_id,
|
||||
};
|
||||
with_state(|state| state.project_opened(label, context, project, open_source, event_time));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn record_analytics_project_leave(window: tauri::Window, project_path: String) {
|
||||
with_state(|state| state.leave(window.label(), Some(&project_path)));
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn record_analytics_ui_save(
|
||||
context: Context,
|
||||
project_path: String,
|
||||
operation_id: String,
|
||||
save_source: SaveSource,
|
||||
changed: bool,
|
||||
event_time: String,
|
||||
) {
|
||||
if !valid_context(&context)
|
||||
|| Uuid::parse_str(&operation_id).is_err()
|
||||
|| project_path.len() > 32768
|
||||
|| !valid_time(&event_time)
|
||||
|| !matches!(save_source, SaveSource::Manual | SaveSource::Auto)
|
||||
|| (save_source == SaveSource::Auto && !changed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(gui) = GUI.get() else { return };
|
||||
let writer = gui.writer.clone();
|
||||
let _ = tauri::async_runtime::spawn_blocking(move || {
|
||||
let Ok(root) = crate::validated_local_project_directory_path(&project_path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else {
|
||||
return;
|
||||
};
|
||||
super::project::saved(
|
||||
Some((context, writer)),
|
||||
&manifest.project_id,
|
||||
Source::UiEditor,
|
||||
&operation_id,
|
||||
ProjectSaved {
|
||||
save_source,
|
||||
revision_id: None,
|
||||
},
|
||||
Some(&event_time),
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) fn mark_restart() {
|
||||
with_state(|state| state.restart = true);
|
||||
}
|
||||
|
||||
pub(crate) fn page_loading(window: &str) {
|
||||
with_state(|state| state.leave(window, None));
|
||||
}
|
||||
|
||||
pub(crate) fn exit() {
|
||||
if let Ok(mut exiting) = EXITING.lock() {
|
||||
*exiting = true;
|
||||
}
|
||||
with_state(GuiState::exit);
|
||||
}
|
||||
|
||||
pub(crate) fn observe_windows(
|
||||
app: &tauri::AppHandle,
|
||||
excluded: Option<&str>,
|
||||
minimized: bool,
|
||||
initial: bool,
|
||||
) {
|
||||
if GUI.get().is_none() {
|
||||
return;
|
||||
}
|
||||
let mut focused = None;
|
||||
let mut unknown = false;
|
||||
for (label, window) in app.webview_windows() {
|
||||
if excluded == Some(label.as_str()) {
|
||||
continue;
|
||||
}
|
||||
match (
|
||||
window.is_focused(),
|
||||
window.is_minimized(),
|
||||
window.is_visible(),
|
||||
) {
|
||||
(Ok(true), Ok(false), Ok(true)) => {
|
||||
focused = Some(label);
|
||||
break;
|
||||
}
|
||||
(Ok(_), Ok(_), Ok(_)) => {}
|
||||
_ => unknown = true,
|
||||
}
|
||||
}
|
||||
if focused.is_none() && unknown {
|
||||
return;
|
||||
}
|
||||
with_state(|state| state.windows(focused, minimized, initial));
|
||||
}
|
||||
|
||||
pub(crate) fn window_event(window: &tauri::Window, event: &tauri::WindowEvent) {
|
||||
let destroyed = matches!(event, tauri::WindowEvent::Destroyed);
|
||||
if destroyed {
|
||||
with_state(|state| state.leave(window.label(), None));
|
||||
}
|
||||
if destroyed
|
||||
|| matches!(
|
||||
event,
|
||||
tauri::WindowEvent::Focused(_) | tauri::WindowEvent::Resized(_)
|
||||
)
|
||||
{
|
||||
observe_windows(
|
||||
window.app_handle(),
|
||||
destroyed.then_some(window.label()),
|
||||
window.is_minimized().unwrap_or(false),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "gui_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use tauri::Manager;
|
||||
|
||||
// 仅串行化发布服务与退出;后台等待认证状态时不占此锁。
|
||||
static EXITING: Mutex<bool> = Mutex::new(false);
|
||||
|
||||
pub(crate) fn initialize(app: tauri::AppHandle, config_dir: PathBuf, version: String) {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
crate::platform_session::initialize_analytics_identity(|route, sequence| {
|
||||
let Ok(exiting) = EXITING.lock() else {
|
||||
return;
|
||||
};
|
||||
if !*exiting {
|
||||
initialize_with_identity(config_dir, version, route, sequence);
|
||||
}
|
||||
});
|
||||
if let Some(gui) = GUI.get() {
|
||||
super::super::goal::retry_pending(&gui.writer);
|
||||
}
|
||||
let handle = app.clone();
|
||||
let _ = app.run_on_main_thread(move || observe_windows(&handle, None, false, true));
|
||||
});
|
||||
}
|
||||
|
||||
fn initialize_with_identity(config_dir: PathBuf, version: String, route: Route, sequence: u64) {
|
||||
if GUI.get().is_some() {
|
||||
return;
|
||||
}
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
let writer = AnalyticsWriter::start(config_dir.clone(), session_id.clone());
|
||||
let context = Context {
|
||||
route,
|
||||
editor_session_id: session_id.clone(),
|
||||
client_version: version.clone(),
|
||||
};
|
||||
let state = GuiState::new(context, sequence, writer.clone());
|
||||
if GUI
|
||||
.set(Service {
|
||||
session_id,
|
||||
version,
|
||||
writer,
|
||||
state: Mutex::new(state),
|
||||
})
|
||||
.is_ok()
|
||||
{
|
||||
with_state(|state| state.start());
|
||||
super::super::upload::start(config_dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn record_analytics_project_open(
|
||||
window: tauri::Window,
|
||||
context: Context,
|
||||
project_path: String,
|
||||
operation_id: String,
|
||||
open_source: OpenSource,
|
||||
event_time: String,
|
||||
) {
|
||||
if !valid_context(&context)
|
||||
|| Uuid::parse_str(&operation_id).is_err()
|
||||
|| project_path.len() > 32768
|
||||
|| !valid_time(&event_time)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let label = window.label().to_string();
|
||||
if with_state(|state| {
|
||||
state.reserve_open(&label, &operation_id, &project_path, &event_time);
|
||||
})
|
||||
.is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 目录/manifest 读取不占用 GUI 线程,也不会把读失败返回给业务操作。
|
||||
let _ = tauri::async_runtime::spawn_blocking(move || {
|
||||
let Ok(root) = crate::validated_local_project_directory_path(&project_path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else {
|
||||
return;
|
||||
};
|
||||
let project = OpenProject {
|
||||
id: manifest.project_id,
|
||||
path: project_path,
|
||||
operation_id,
|
||||
};
|
||||
with_state(|state| state.project_opened(label, context, project, open_source, event_time));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn record_analytics_project_leave(window: tauri::Window, project_path: String) {
|
||||
with_state(|state| state.leave(window.label(), Some(&project_path)));
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn record_analytics_ui_save(
|
||||
context: Context,
|
||||
project_path: String,
|
||||
operation_id: String,
|
||||
save_source: SaveSource,
|
||||
changed: bool,
|
||||
event_time: String,
|
||||
) {
|
||||
if !valid_context(&context)
|
||||
|| Uuid::parse_str(&operation_id).is_err()
|
||||
|| project_path.len() > 32768
|
||||
|| !valid_time(&event_time)
|
||||
|| !matches!(save_source, SaveSource::Manual | SaveSource::Auto)
|
||||
|| (save_source == SaveSource::Auto && !changed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(gui) = GUI.get() else { return };
|
||||
let writer = gui.writer.clone();
|
||||
let _ = tauri::async_runtime::spawn_blocking(move || {
|
||||
let Ok(root) = crate::validated_local_project_directory_path(&project_path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else {
|
||||
return;
|
||||
};
|
||||
super::super::project::saved(
|
||||
Some((context, writer)),
|
||||
&manifest.project_id,
|
||||
Source::UiEditor,
|
||||
&operation_id,
|
||||
ProjectSaved {
|
||||
save_source,
|
||||
revision_id: None,
|
||||
},
|
||||
Some(&event_time),
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) fn mark_restart() {
|
||||
with_state(|state| state.restart = true);
|
||||
}
|
||||
|
||||
pub(crate) fn page_loading(window: &str) {
|
||||
with_state(|state| state.leave(window, None));
|
||||
}
|
||||
|
||||
pub(crate) fn exit() {
|
||||
if let Ok(mut exiting) = EXITING.lock() {
|
||||
*exiting = true;
|
||||
}
|
||||
with_state(GuiState::exit);
|
||||
}
|
||||
|
||||
pub(crate) fn observe_windows(
|
||||
app: &tauri::AppHandle,
|
||||
excluded: Option<&str>,
|
||||
minimized: bool,
|
||||
initial: bool,
|
||||
) {
|
||||
if GUI.get().is_none() {
|
||||
return;
|
||||
}
|
||||
let mut focused = None;
|
||||
let mut unknown = false;
|
||||
for (label, window) in app.webview_windows() {
|
||||
if excluded == Some(label.as_str()) {
|
||||
continue;
|
||||
}
|
||||
match (
|
||||
window.is_focused(),
|
||||
window.is_minimized(),
|
||||
window.is_visible(),
|
||||
) {
|
||||
(Ok(true), Ok(false), Ok(true)) => {
|
||||
focused = Some(label);
|
||||
break;
|
||||
}
|
||||
(Ok(_), Ok(_), Ok(_)) => {}
|
||||
_ => unknown = true,
|
||||
}
|
||||
}
|
||||
if focused.is_none() && unknown {
|
||||
return;
|
||||
}
|
||||
with_state(|state| state.windows(focused, minimized, initial));
|
||||
}
|
||||
|
||||
pub(crate) fn window_event(window: &tauri::Window, event: &tauri::WindowEvent) {
|
||||
let destroyed = matches!(event, tauri::WindowEvent::Destroyed);
|
||||
if destroyed {
|
||||
with_state(|state| state.leave(window.label(), None));
|
||||
}
|
||||
if destroyed
|
||||
|| matches!(
|
||||
event,
|
||||
tauri::WindowEvent::Focused(_) | tauri::WindowEvent::Resized(_)
|
||||
)
|
||||
{
|
||||
observe_windows(
|
||||
window.app_handle(),
|
||||
destroyed.then_some(window.label()),
|
||||
window.is_minimized().unwrap_or(false),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user