From 9f225b7a41d43a5d156b9b413b881bb33e9c4092 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 21 Sep 2026 12:18:47 +0000 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=9F=8B=E7=82=B9=E4=B8=8E=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E9=87=87=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增统一事件合同、非阻塞明文队列、批次封存及保留清理 接入会话前台时长、项目创建打开、创作提交和双智能体运行结果 按策划阶段推进及已接入成果路径记录变化、预览就绪与保存 补齐身份隔离、异常会话和同一宿主创作链路验证 同步团队方案与验收证据,本期不上传、不加密、不扩展资源操作采集 --- .../src-tauri/src/agent/design_runtime.rs | 863 +++++++++++++++- .../src-tauri/src/agent/direct_execution.rs | 122 ++- .../src/agent/direct_execution/tests.rs | 72 ++ .../src-tauri/src/agent/direct_patch.rs | 112 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 209 +++- .../src/agent/direct_runtime/user_input.rs | 4 + .../src-tauri/src/agent/direct_tool_bridge.rs | 380 +++++++ .../src-tauri/src/agent/direct_validation.rs | 5 +- .../agent/runtime_protocol/design_session.rs | 3 + .../src-tauri/src/analytics/contract.rs | 529 ++++++++++ .../src-tauri/src/analytics/contract_tests.rs | 277 +++++ .../src-tauri/src/analytics/design.rs | 55 + .../src-tauri/src/analytics/goal.rs | 166 +++ .../src-tauri/src/analytics/gui.rs | 644 ++++++++++++ .../src-tauri/src/analytics/gui_tests.rs | 189 ++++ .../src-tauri/src/analytics/mod.rs | 10 + .../src-tauri/src/analytics/preview.rs | 146 +++ .../src-tauri/src/analytics/project.rs | 104 ++ .../src-tauri/src/analytics/run.rs | 400 ++++++++ .../src-tauri/src/analytics/session.rs | 248 +++++ .../src-tauri/src/analytics/session_tests.rs | 101 ++ .../src-tauri/src/analytics/store.rs | 916 +++++++++++++++++ .../src-tauri/src/analytics/store_tests.rs | 971 ++++++++++++++++++ .../src-tauri/src/commands.rs | 239 ++++- .../src/commands_manual_analytics_tests.rs | 264 +++++ .../src-tauri/src/main.rs | 64 +- .../src-tauri/src/platform_session.rs | 67 +- .../src-tauri/src/preview.rs | 49 +- .../src-tauri/src/preview_analytics_tests.rs | 297 ++++++ .../src-tauri/src/template_library.rs | 15 +- apps/ai-game-creator-shell/src/App.tsx | 28 +- .../features/app-shell/WorkspaceLauncher.tsx | 28 +- .../app-shell/useHomeProjectCreation.ts | 225 ++-- .../template-library/useTemplateLibrary.ts | 8 +- .../src/services/clientAnalytics.ts | 134 +++ .../src/view/ui-editor/useUiEditorPage.ts | 19 +- .../appSurface/project-commands.suite.ts | 5 + .../appSurface/project-development.suite.ts | 2 + .../assert-project-tools-and-preview.ts | 1 + .../tests/clientAnalytics.test.tsx | 333 ++++++ .../tests/directRunAnalytics.test.ts | 91 ++ .../tests/homeWebPreflight.test.tsx | 2 +- .../tests/uiEditorPage.test.ts | 125 +++ docs/README.md | 2 +- .../shared-memory/decision-log.md | 10 +- ...案】客户端本地埋点与主站入库契约-2026-09-21.md | 162 +-- 46 files changed, 8483 insertions(+), 213 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/contract.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/contract_tests.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/design.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/goal.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/gui_tests.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/preview.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/project.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/session.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/session_tests.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/commands_manual_analytics_tests.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/preview_analytics_tests.rs create mode 100644 apps/ai-game-creator-shell/src/services/clientAnalytics.ts create mode 100644 apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/directRunAnalytics.test.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index e3c2c6f9e..8c7954e5a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -1,12 +1,13 @@ use super::design_tools::*; use super::*; +use crate::analytics::contract::{ErrorCode, RunEndReason, RunSource, Source}; use futures::FutureExt; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::fs::File; use std::path::{Path, PathBuf}; use std::sync::OnceLock; -use std::time::Duration; +use std::time::{Duration, Instant}; use tauri::Emitter; use uuid::Uuid; @@ -335,6 +336,7 @@ fn begin_design_turn(session: &mut DesignSession, id: &str) { request_index: 0, attempt: 0, model_selection: None, + analytics: None, }); session.last_error = None; session.updated_at = unix_timestamp(); @@ -725,12 +727,66 @@ fn design_debug(root: &Path, kind: &str, data: Value) { let _ = sender.try_send((path, data)); } +#[derive(Debug)] +struct DesignFailure { + message: String, + code: ErrorCode, +} + +impl From for DesignFailure { + fn from(message: String) -> Self { + Self { + message, + code: ErrorCode::RuntimeErrorUnclassified, + } + } +} + +impl From<&str> for DesignFailure { + fn from(message: &str) -> Self { + message.to_string().into() + } +} + +impl DesignFailure { + fn local_io(message: String) -> Self { + Self { + message, + code: ErrorCode::LocalIoFailed, + } + } +} + +fn design_provider_failure(error: &platform_llm::LlmError, message: String) -> DesignFailure { + use platform_llm::LlmError; + let code = match error { + LlmError::Timeout { .. } => ErrorCode::ProviderTimeout, + LlmError::Upstream { + status_code: 401 | 403, + .. + } => ErrorCode::ProviderAuthFailed, + LlmError::Upstream { + status_code: 429, .. + } => ErrorCode::ProviderRateLimited, + LlmError::Upstream { + status_code: 500..=599, + .. + } + | LlmError::Connectivity { .. } + | LlmError::Transport(_) + | LlmError::StreamUnavailable => ErrorCode::ProviderUnavailable, + LlmError::EmptyResponse | LlmError::Deserialize(_) => ErrorCode::ProviderInvalidResponse, + _ => ErrorCode::RuntimeErrorUnclassified, + }; + DesignFailure { message, code } +} + async fn request_design_provider( root: &Path, session: &mut DesignSession, resources: &DesignResources, emit: &mut (impl FnMut(DesignEvent) + Send), -) -> Result { +) -> Result { #[cfg(test)] if fake_provider::is_active() { return request_scripted_design_provider(root, session, emit).await; @@ -749,7 +805,7 @@ async fn request_design_provider( let message_id = format!("{}:response:{}", turn.id, turn.request_index); for attempt in 0..=max_retries { session.turn.as_mut().unwrap().attempt = attempt; - checkpoint_design(root, session)?; + checkpoint_design(root, session).map_err(DesignFailure::local_io)?; design_debug( root, "request", @@ -870,7 +926,7 @@ async fn request_design_provider( Some(&message_id), String::new(), )); - return Err(detail); + return Err(design_provider_failure(&error, detail)); } tokio::time::sleep(Duration::from_millis( game_creator_agent_runtime_transient_retry_backoff_ms( @@ -890,14 +946,14 @@ async fn request_scripted_design_provider( root: &Path, session: &mut DesignSession, emit: &mut (impl FnMut(DesignEvent) + Send), -) -> Result { +) -> Result { let max_retries = fake_provider::max_retries(); let turn = session.turn.as_ref().unwrap(); let turn_id = turn.id.clone(); let message_id = format!("{}:response:{}", turn.id, turn.request_index); for attempt in 0..=max_retries { session.turn.as_mut().unwrap().attempt = attempt; - checkpoint_design(root, session)?; + checkpoint_design(root, session).map_err(DesignFailure::local_io)?; emit(design_event( root, &turn_id, @@ -952,7 +1008,7 @@ async fn request_scripted_design_provider( Some(&message_id), String::new(), )); - return Err(detail); + return Err(design_provider_failure(&error, detail)); } } None => { @@ -1010,15 +1066,18 @@ async fn run_design_loop( resources: &DesignResources, session: &mut DesignSession, emit: &mut (impl FnMut(DesignEvent) + Send), -) -> Result<(), String> { +) -> Result<(), DesignFailure> { while session.turn.as_ref().is_some_and(|turn| turn.pending) { - process_design_batch(root, resources, session, emit)?; + process_design_batch(root, resources, session, emit).map_err(DesignFailure::local_io)?; if session.pending_approval.is_some() || session.pending_clarification.is_some() { break; } let response = request_design_provider(root, session, resources, emit).await?; - accept_design_response(session, response)?; - checkpoint_design(root, session)?; + accept_design_response(session, response).map_err(|message| DesignFailure { + message, + code: ErrorCode::ProviderInvalidResponse, + })?; + checkpoint_design(root, session).map_err(DesignFailure::local_io)?; let turn = session.turn.as_ref().unwrap(); emit(design_event( root, @@ -1032,14 +1091,28 @@ async fn run_design_loop( Ok(()) } +#[derive(Clone, Copy, PartialEq, Eq)] +enum DesignExecution { + Idle, + New, + Recovery, +} + async fn finish_design_command( root: &Path, resources: &DesignResources, mut session: DesignSession, active: File, - run: bool, + execution: DesignExecution, + phase_change: Option, + capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, mut emit: impl FnMut(DesignEvent) + Send, ) -> Result { + let run = execution != DesignExecution::Idle; + let new_run = execution == DesignExecution::New; if run && session .turn @@ -1049,6 +1122,25 @@ async fn finish_design_command( resolve_design_turn_llm_config(&mut session, &load_game_creator_app_config()?)?; } checkpoint_design(root, &session)?; + let started = (new_run && capture.is_some()).then(Instant::now); + let metadata = session + .turn + .as_ref() + .and_then(|turn| turn.analytics.clone()); + if new_run { + if let (Some(metadata), Some((_, writer))) = (&metadata, &capture) { + crate::analytics::run::accepted(writer, root, &session.project_id, metadata); + } + crate::analytics::goal::accepted( + capture.clone(), + root, + &session.project_id, + crate::analytics::contract::Source::DesignAgent, + ); + } + if let Some(change) = phase_change { + change.record(); + } let turn_id = session .turn .as_ref() @@ -1074,12 +1166,43 @@ async fn finish_design_command( let outcome = std::panic::AssertUnwindSafe(run).catch_unwind().await; let result = match outcome { Ok(result) => result, - Err(payload) => Err(design_panic_error(payload)), + Err(payload) => Err(DesignFailure { + message: design_panic_error(payload), + code: ErrorCode::RuntimeFailed, + }), }; + if let Some(metadata) = &metadata { + let end_reason = if result.is_err() { + RunEndReason::Failed + } else if session.pending_approval.is_some() { + RunEndReason::WaitingForApproval + } else if session.pending_clarification.is_some() { + RunEndReason::WaitingForUser + } else { + RunEndReason::Finished + }; + crate::analytics::run::finished( + capture + .clone() + .or_else(crate::analytics::gui::capture_writer_context), + root, + &session.project_id, + metadata, + crate::analytics::run::Outcome { + turn_id: Some(turn_id.clone()), + end_reason, + error_code: result.as_ref().err().map(|error| error.code), + duration_ms: started + .and_then(|start| u64::try_from(start.elapsed().as_millis()).ok()), + output_change_detected: metadata.output_revision.as_ref().map(|_| true), + revision_id: metadata.output_revision.clone(), + }, + ); + } if let Err(error) = result { // 从最后一个持久检查点恢复,防止写后未记结果被误认为已完成。 session = read_design_session(root)?.ok_or("策划会话丢失")?; - session.last_error = Some(redact_agent_runtime_error(root, &error, 1800)); + session.last_error = Some(redact_agent_runtime_error(root, &error.message, 1800)); checkpoint_design(root, &session)?; } } @@ -1108,6 +1231,21 @@ pub(crate) async fn continue_design_agent_at( id: &str, input: DesignInput, emit: impl FnMut(DesignEvent) + Send, +) -> Result { + let capture = crate::analytics::gui::capture_writer_context(); + continue_design_agent_with_capture_at(root, resources, id, input, capture, emit).await +} + +async fn continue_design_agent_with_capture_at( + root: &Path, + resources: &DesignResources, + id: &str, + input: DesignInput, + capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, + emit: impl FnMut(DesignEvent) + Send, ) -> Result { ensure_design_runtime_active(root)?; let project_id = design_project_id(root)?; @@ -1121,11 +1259,36 @@ pub(crate) async fn continue_design_agent_at( if session.project_id != project_id { return Err("策划会话与当前项目不匹配".into()); } + let run_source = match &input { + DesignInput::Retry => RunSource::UserRetry, + DesignInput::Clarification { .. } => RunSource::Clarification, + DesignInput::Message { .. } if session.pending_clarification.is_some() => { + RunSource::Clarification + } + DesignInput::Message { .. } => RunSource::UserSubmit, + }; let run = prepare_design_input(&mut session, id, input)?; if run { select_design_turn_model(&mut session, &load_game_creator_app_config()?)?; + session.turn.as_mut().unwrap().analytics = capture.as_ref().map(|(context, _)| { + crate::analytics::run::Metadata::new(context.clone(), Source::DesignAgent, run_source) + }); } - finish_design_command(root, resources, session, active, run, emit).await + finish_design_command( + root, + resources, + session, + active, + if run { + DesignExecution::New + } else { + DesignExecution::Idle + }, + None, + capture, + emit, + ) + .await } async fn recover_uncertain_design_batch( @@ -1134,7 +1297,18 @@ async fn recover_uncertain_design_batch( session: DesignSession, active: File, ) -> Result { - finish_design_command(root, resources, session, active, true, |_| {}).await + let capture = crate::analytics::gui::capture_writer_context(); + finish_design_command( + root, + resources, + session, + active, + DesignExecution::Recovery, + None, + capture, + |_| {}, + ) + .await } pub(crate) async fn decide_design_phase_at( @@ -1144,6 +1318,23 @@ pub(crate) async fn decide_design_phase_at( request_id: &str, approved: bool, emit: impl FnMut(DesignEvent) + Send, +) -> Result { + let capture = crate::analytics::gui::capture_writer_context(); + decide_design_phase_with_capture_at(root, resources, id, request_id, approved, capture, emit) + .await +} + +async fn decide_design_phase_with_capture_at( + root: &Path, + resources: &DesignResources, + id: &str, + request_id: &str, + approved: bool, + capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, + emit: impl FnMut(DesignEvent) + Send, ) -> Result { ensure_design_runtime_active(root)?; let project_id = design_project_id(root)?; @@ -1154,11 +1345,48 @@ pub(crate) async fn decide_design_phase_at( if session.project_id != project_id { return Err("策划会话与当前项目不匹配".into()); } + let previous_phase = session.current_phase.clone(); let run = prepare_design_decision(&mut session, id, request_id, approved)?; + let phase_change = run + .then(|| { + crate::analytics::design::PhaseChange::new( + capture.clone(), + &project_id, + &session.session_id, + &previous_phase, + &session.current_phase, + ) + }) + .flatten(); if run { select_design_turn_model(&mut session, &load_game_creator_app_config()?)?; + session.turn.as_mut().unwrap().analytics = capture.as_ref().map(|(context, _)| { + let mut metadata = crate::analytics::run::Metadata::new( + context.clone(), + Source::DesignAgent, + RunSource::Approval, + ); + metadata.output_revision = phase_change + .as_ref() + .map(|change| change.revision_id().to_string()); + metadata + }); } - finish_design_command(root, resources, session, active, run, emit).await + finish_design_command( + root, + resources, + session, + active, + if run { + DesignExecution::New + } else { + DesignExecution::Idle + }, + phase_change, + capture, + emit, + ) + .await } fn ensure_design_runtime_active(root: &Path) -> Result<(), String> { @@ -1392,12 +1620,20 @@ pub(crate) async fn continue_design_agent_session( client_turn_id: String, input: DesignInput, ) -> Result { + let capture = crate::analytics::gui::capture_writer_context(); let root = PathBuf::from(project_path.trim()); enforce_project_permission_policy(&root, "conversation.write")?; let resources = DesignResources::new(resolve_design_resources_root(&app)?)?; - continue_design_agent_at(&root, &resources, &client_turn_id, input, |event| { - let _ = app.emit("design-agent-update", event); - }) + continue_design_agent_with_capture_at( + &root, + &resources, + &client_turn_id, + input, + capture, + |event| { + let _ = app.emit("design-agent-update", event); + }, + ) .await } @@ -1409,15 +1645,17 @@ pub(crate) async fn decide_design_phase( request_id: String, approved: bool, ) -> Result { + let capture = crate::analytics::gui::capture_writer_context(); let root = PathBuf::from(project_path.trim()); enforce_project_permission_policy(&root, "conversation.write")?; let resources = DesignResources::new(resolve_design_resources_root(&app)?)?; - decide_design_phase_at( + decide_design_phase_with_capture_at( &root, &resources, &client_turn_id, &request_id, approved, + capture, |event| { let _ = app.emit("design-agent-update", event); }, @@ -1499,6 +1737,64 @@ mod fake_provider { mod tests { use super::*; + fn analytics_capture() -> ( + tempfile::TempDir, + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + ) { + let temp = tempfile::tempdir().unwrap(); + let context = crate::analytics::contract::Context { + route: crate::analytics::contract::Route::from_identity( + Some("approver-a".into()), + Some("https://example.com"), + ), + editor_session_id: Uuid::new_v4().to_string(), + client_version: "1.0.0".into(), + }; + let writer = crate::analytics::store::AnalyticsWriter::start( + temp.path().into(), + context.editor_session_id.clone(), + ); + (temp, context, writer) + } + + fn read_analytics_events( + config: &Path, + session: &str, + marker_id: &str, + ) -> Vec { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + let mut events = Vec::new(); + if let Ok(batches) = fs::read_dir( + config + .join("analytics/instances") + .join(session) + .join("batches"), + ) { + for batch in batches.flatten() { + if Uuid::parse_str(&batch.file_name().to_string_lossy()).is_err() { + continue; + } + if let Ok(lines) = fs::read_to_string(batch.path().join("events.jsonl")) { + events.extend(lines.lines().map(|line| { + serde_json::from_str::(line).unwrap() + })); + } + } + } + if events.iter().any(|event| event.event_id == marker_id) { + return events; + } + assert!( + std::time::Instant::now() < deadline, + "等待 FIFO 哨兵超时,已读取 {} 条事件", + events.len() + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + #[test] fn runtime_mode_restore_prefers_explicit_mode_over_existing_design_session() { let temporary = tempfile::tempdir().expect("tempdir"); @@ -1530,6 +1826,448 @@ mod tests { ); } + fn drain_analytics_with_marker( + config: &Path, + context: &crate::analytics::contract::Context, + writer: &crate::analytics::store::AnalyticsWriter, + ) -> Vec { + use crate::analytics::contract::*; + // 同一 FIFO 队列中的哨兵落盘后,之前可能误发的事件也一定已被处理。 + let marker = context + .capture( + EventData::EditorSessionStart(SessionStart { + entry_source: EntrySource::DirectLaunch, + first_project_id: None, + }), + None, + Source::Editor, + None, + ) + .unwrap(); + let marker_id = marker.event_id.clone(); + assert!(writer.try_record(context.route.clone(), marker, marker_id.clone())); + assert!(writer.flush()); + let events = read_analytics_events(config, &context.editor_session_id, &marker_id); + assert!(events.iter().any(|event| event.event_id == marker_id)); + events + } + + fn assert_revision_count_after_drain( + config: &Path, + context: &crate::analytics::contract::Context, + writer: &crate::analytics::store::AnalyticsWriter, + expected: usize, + ) { + let events = drain_analytics_with_marker(config, context, writer); + assert_eq!( + events + .iter() + .filter(|event| event.event_name == "project_revision_created") + .count(), + expected + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn approved_phase_revision_survives_model_failure_and_command_replay() { + let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); + concept_artifacts(&root); + let mut session = new_design_session(design_project_id(&root).unwrap(), "quality"); + let approval = submit_design_phase_for_approval(&root, &mut session).unwrap(); + write_design_session(&root, &session).unwrap(); + let _fake = fake_provider::install( + vec![Err(platform_llm::LlmError::Upstream { + status_code: 500, + message: "test failure".into(), + })], + 0, + ); + let view = decide_design_phase_with_capture_at( + &root, + &resources, + "approval-once", + &approval.request_id, + true, + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .unwrap(); + assert_eq!(view.session.current_phase, "top_design"); + assert!(view.session.last_error.is_some()); + let events = drain_analytics_with_marker(analytics_dir.path(), &context, &writer); + let revision_event = events + .iter() + .find(|event| event.event_name == "project_revision_created") + .unwrap(); + assert_eq!( + revision_event.properties["revision_id"], + format!("design:{}:top_design", session.session_id) + ); + assert_eq!(revision_event.user_id.as_deref(), Some("approver-a")); + let failed = events + .iter() + .find(|event| event.event_name == "agent_run_failed") + .unwrap(); + assert_eq!(failed.properties["output_change_detected"], true); + assert_eq!( + failed.properties["revision_id"], + revision_event.properties["revision_id"] + ); + assert_eq!(failed.error_code, Some(ErrorCode::ProviderUnavailable)); + assert_eq!(failed.properties["run_source"], "approval"); + + let mut another_user = context.clone(); + another_user.route.user_id = Some("approver-b".into()); + decide_design_phase_with_capture_at( + &root, + &resources, + "approval-once", + &approval.request_id, + true, + Some((another_user, writer.clone())), + |_| {}, + ) + .await + .unwrap(); + assert_revision_count_after_drain(analytics_dir.path(), &context, &writer, 1); + } + + #[tokio::test(flavor = "current_thread")] + async fn phase_checkpoint_failure_does_not_record_revision_or_start_model() { + let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); + concept_artifacts(&root); + let mut session = new_design_session(design_project_id(&root).unwrap(), "quality"); + crate::analytics::goal::created(&writer, &root, &session.project_id); + let approval = submit_design_phase_for_approval(&root, &mut session).unwrap(); + let old_phase = session.current_phase.clone(); + assert!( + prepare_design_decision(&mut session, "approve", &approval.request_id, true).unwrap() + ); + select_design_turn_model(&mut session, &GameCreatorAppConfig::default()).unwrap(); + let change = crate::analytics::design::PhaseChange::new( + Some((context.clone(), writer.clone())), + &session.project_id, + &session.session_id, + &old_phase, + &session.current_phase, + ); + fs::create_dir_all(root.join(".agent/design-agent/session.json")).unwrap(); + let active = try_open_game_creator_agent_runtime_task_lock_file(&root, DESIGN_ACTIVE_LOCK) + .unwrap() + .unwrap(); + let _fake = fake_provider::install(vec![Ok(fake_response("unused", "unused", vec![]))], 0); + assert!(finish_design_command( + &root, + &resources, + session, + active, + DesignExecution::New, + change, + Some((context.clone(), writer.clone())), + |_| {} + ) + .await + .is_err()); + assert_revision_count_after_drain(analytics_dir.path(), &context, &writer, 0); + assert!(fake_provider::take().is_some()); + } + + #[tokio::test(flavor = "current_thread")] + async fn design_first_submit_keeps_accepting_user_after_model_failure_and_replay() { + let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); + crate::analytics::goal::created(&writer, &root, &design_project_id(&root).unwrap()); + let _fake = fake_provider::install( + vec![Err(platform_llm::LlmError::Upstream { + status_code: 500, + message: "test failure".into(), + })], + 0, + ); + let input = || DesignInput::Message { + text: "设计一个游戏".into(), + }; + let view = continue_design_agent_with_capture_at( + &root, + &resources, + "first-submit", + input(), + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .unwrap(); + assert!(view.session.last_error.is_some()); + let mut another_user = context.clone(); + another_user.route.user_id = Some("approver-b".into()); + continue_design_agent_with_capture_at( + &root, + &resources, + "first-submit", + input(), + Some((another_user, writer.clone())), + |_| {}, + ) + .await + .unwrap(); + let events = drain_analytics_with_marker(analytics_dir.path(), &context, &writer); + let submitted = events + .iter() + .find(|event| event.event_name == "creative_task_submit") + .unwrap(); + assert_eq!(submitted.user_id.as_deref(), Some("approver-a")); + assert_eq!( + submitted.source, + crate::analytics::contract::Source::DesignAgent + ); + assert_eq!(submitted.project_id, submitted.creative_task_id); + } + + #[tokio::test(flavor = "current_thread")] + async fn design_run_wait_failure_and_user_retry_have_distinct_stable_results() { + let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); + let _fake = fake_provider::install( + vec![ + Ok(fake_response( + "question", + "", + vec![platform_llm::LlmToolCall { + id: "ask".into(), + name: "ask_clarification".into(), + arguments: json!({"question":"选择方向?","options":["解谜"]}).to_string(), + }], + )), + Err(platform_llm::LlmError::Upstream { + status_code: 401, + message: "private provider detail".into(), + }), + Err(platform_llm::LlmError::Upstream { + status_code: 500, + message: "transient".into(), + }), + Ok(fake_response("answer", "完成本轮", vec![])), + ], + 1, + ); + let view = continue_design_agent_with_capture_at( + &root, + &resources, + "start", + DesignInput::Message { + text: "设计游戏".into(), + }, + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .unwrap(); + assert!(view.session.pending_clarification.is_some()); + let failed = continue_design_agent_with_capture_at( + &root, + &resources, + "answer", + DesignInput::Message { + text: "解谜".into(), + }, + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .unwrap(); + assert!(failed.session.last_error.is_some()); + continue_design_agent_with_capture_at( + &root, + &resources, + "retry", + DesignInput::Retry, + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .unwrap(); + continue_design_agent_with_capture_at( + &root, + &resources, + "retry", + DesignInput::Retry, + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .unwrap(); + let events = drain_analytics_with_marker(analytics_dir.path(), &context, &writer); + let runs: Vec<_> = events.iter().filter(|e| e.agent_run_id.is_some()).collect(); + assert_eq!(runs.len(), 3); + let find = |turn: &str| { + *runs + .iter() + .find(|e| e.agent_turn_id.as_deref() == Some(turn)) + .unwrap() + }; + let first = find("start"); + let failed = find("answer"); + let retry = find("retry"); + assert_eq!(first.properties["end_reason"], "waiting_for_user"); + assert_eq!(first.properties["run_source"], "user_submit"); + assert_eq!(failed.error_code, Some(ErrorCode::ProviderAuthFailed)); + assert_eq!(failed.properties["run_source"], "clarification"); + assert_eq!(failed.properties["retry_index"], 0); + assert_eq!(retry.properties["run_source"], "user_retry"); + assert_eq!(retry.properties["retry_index"], 1); + assert_eq!(retry.properties["end_reason"], "finished"); + assert_ne!(first.agent_run_id, failed.agent_run_id); + assert_ne!(failed.agent_run_id, retry.agent_run_id); + for event in runs { + assert!(event.properties["duration_ms"].is_u64()); + assert!(event.properties["output_change_detected"].is_null()); + assert!(!event + .properties + .to_string() + .contains("private provider detail")); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn design_recovered_run_keeps_original_identity_and_has_unknown_duration() { + let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); + let mut session = new_design_session(&design_project_id(&root).unwrap(), ""); + begin_design_turn(&mut session, "recovered-turn"); + let metadata = crate::analytics::run::Metadata::new( + context.clone(), + Source::DesignAgent, + RunSource::UserSubmit, + ); + session.turn.as_mut().unwrap().analytics = Some(metadata.clone()); + write_design_session(&root, &session).unwrap(); + crate::analytics::run::accepted(&writer, &root, &session.project_id, &metadata); + drain_analytics_with_marker(analytics_dir.path(), &context, &writer); + let mut current = context.clone(); + current.editor_session_id = Uuid::new_v4().to_string(); + current.client_version = "2.0.0".into(); + current.route.user_id = Some("restoring-user".into()); + let current_writer = crate::analytics::store::AnalyticsWriter::start( + analytics_dir.path().into(), + current.editor_session_id.clone(), + ); + let _fake = + fake_provider::install(vec![Ok(fake_response("restored", "恢复后完成", vec![]))], 0); + let active = try_open_game_creator_agent_runtime_task_lock_file(&root, DESIGN_ACTIVE_LOCK) + .unwrap() + .unwrap(); + finish_design_command( + &root, + &resources, + read_design_session(&root).unwrap().unwrap(), + active, + DesignExecution::Recovery, + None, + Some((current.clone(), current_writer.clone())), + |_| {}, + ) + .await + .unwrap(); + let events = drain_analytics_with_marker(analytics_dir.path(), ¤t, ¤t_writer); + let result = events + .iter() + .find(|event| event.event_name == "agent_run_completed") + .unwrap(); + assert_eq!( + result.agent_run_id.as_deref(), + Some(metadata.run_id.as_str()) + ); + assert_eq!(result.event_id, metadata.terminal_event_id); + assert_eq!(result.user_id, context.route.user_id); + assert_eq!(result.editor_session_id, current.editor_session_id); + assert_eq!(result.client_version, "2.0.0"); + assert!(result.properties["duration_ms"].is_null()); + assert_eq!(result.properties["retry_index"], 0); + assert!(!events + .iter() + .any(|event| event.event_name == "creative_task_submit")); + } + + #[tokio::test(flavor = "current_thread")] + async fn design_old_command_replay_does_not_consume_newly_available_goal_qualification() { + let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); + let _fake = fake_provider::install(vec![Ok(fake_response("answer", "完成", vec![]))], 0); + let input = || DesignInput::Message { + text: "设计一个游戏".into(), + }; + continue_design_agent_with_capture_at( + &root, + &resources, + "old-command", + input(), + None, + |_| {}, + ) + .await + .unwrap(); + crate::analytics::goal::created(&writer, &root, &design_project_id(&root).unwrap()); + continue_design_agent_with_capture_at( + &root, + &resources, + "old-command", + input(), + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .unwrap(); + let events = drain_analytics_with_marker(analytics_dir.path(), &context, &writer); + assert!(!events + .iter() + .any(|event| event.event_name == "creative_task_submit")); + } + + #[tokio::test(flavor = "current_thread")] + async fn design_invalid_input_preserves_qualification_for_later_accepting_user() { + let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); + crate::analytics::goal::created(&writer, &root, &design_project_id(&root).unwrap()); + assert!(continue_design_agent_with_capture_at( + &root, + &resources, + "invalid", + DesignInput::Message { text: " ".into() }, + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .is_err()); + let mut accepting_user = context.clone(); + accepting_user.route.user_id = Some("approver-b".into()); + let _fake = fake_provider::install(vec![Ok(fake_response("answer", "完成", vec![]))], 0); + continue_design_agent_with_capture_at( + &root, + &resources, + "valid", + DesignInput::Message { + text: "设计游戏".into(), + }, + Some((accepting_user, writer.clone())), + |_| {}, + ) + .await + .unwrap(); + let events = drain_analytics_with_marker(analytics_dir.path(), &context, &writer); + assert_eq!( + events + .iter() + .find(|event| event.event_name == "creative_task_submit") + .unwrap() + .user_id + .as_deref(), + Some("approver-b") + ); + } + #[test] fn design_runtime_rejects_design_execution_in_game_mode() { let temporary = tempfile::tempdir().expect("create runtime mode root"); @@ -1818,6 +2556,7 @@ mod tests { request_index: 0, attempt: 0, model_selection: None, + analytics: None, }); session.pending_batch = Some(DesignToolBatch { calls: vec![ @@ -2228,6 +2967,7 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn fake_provider_walks_five_phases_and_enters_consultant() { let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); let _fake = fake_provider::install( vec![ Ok(phase_write_and_submit( @@ -2286,9 +3026,17 @@ mod tests { ("t-tdd", "tdd"), ("t-consultant", "consultant"), ] { - let view = decide_design_phase_at(&root, &resources, turn, &request, true, |_| {}) - .await - .expect("approve"); + let view = decide_design_phase_with_capture_at( + &root, + &resources, + turn, + &request, + true, + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .expect("approve"); assert_eq!(view.session.current_phase, expected); if expected == "consultant" { assert!(view.session.pending_approval.is_none()); @@ -2308,11 +3056,60 @@ mod tests { design_workflow_status(&restored)["current_phase"], json!("consultant") ); + let events = drain_analytics_with_marker(analytics_dir.path(), &context, &writer); + let revisions: Vec<_> = events + .iter() + .filter(|event| event.event_name == "project_revision_created") + .collect(); + assert_eq!(revisions.len(), 5); + let completed: Vec<_> = events + .iter() + .filter(|event| event.event_name == "agent_run_completed") + .collect(); + assert_eq!(completed.len(), 5); + assert_eq!( + completed + .iter() + .filter(|e| e.properties["end_reason"] == "waiting_for_approval") + .count(), + 4 + ); + assert_eq!( + completed + .iter() + .filter(|e| e.properties["end_reason"] == "finished") + .count(), + 1 + ); + assert!(completed + .iter() + .all(|e| e.properties["output_change_detected"] == true + && e.properties["retry_index"] == 0)); + for phase in &DESIGN_PHASES[1..] { + let revision = format!("design:{}:{phase}", restored.session_id); + let event = revisions + .iter() + .find(|event| event.properties["revision_id"] == revision) + .unwrap(); + assert_eq!(event.event_name, "project_revision_created"); + assert_eq!(event.user_id.as_deref(), Some("approver-a")); + assert_eq!(event.project_id, Some(restored.project_id.clone())); + assert_eq!(event.creative_task_id, event.project_id); + assert_eq!( + event.source, + crate::analytics::contract::Source::DesignAgent + ); + assert_eq!(event.properties["revision_source"], "agent"); + assert_eq!(event.properties["change_kind"], "design_document"); + assert!(event.properties.get("files_changed_count").is_none()); + assert!(event.agent_run_id.is_none() && event.agent_turn_id.is_none()); + } } #[tokio::test(flavor = "current_thread")] async fn fake_provider_reject_does_not_wake_and_session_survives_restart() { let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); let _fake = fake_provider::install( vec![Ok(phase_write_and_submit( "concept", @@ -2344,14 +3141,23 @@ mod tests { .map(|item| item.request_id.as_str()), Some(request.as_str()) ); - let rejected = - decide_design_phase_at(&root, &resources, "t-reject", &request, false, |_| {}) - .await - .expect("reject"); + crate::analytics::goal::created(&writer, &root, &persisted.project_id); + let rejected = decide_design_phase_with_capture_at( + &root, + &resources, + "t-reject", + &request, + false, + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .expect("reject"); assert_eq!(rejected.session.current_phase, "concept"); assert!(rejected.session.pending_approval.is_none()); assert!(rejected.session.approved_phases.is_empty()); assert!(fake_provider::take().is_none()); + assert_revision_count_after_drain(analytics_dir.path(), &context, &writer, 0); let debug = root.join(".debug/design-agent"); if debug.exists() { @@ -2469,6 +3275,7 @@ mod tests { request_index: 0, attempt: 0, model_selection: None, + analytics: None, }); session.pending_batch = Some(DesignToolBatch { calls: vec![call], diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs index 8f276ad31..af3a03f1a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs @@ -93,10 +93,17 @@ pub(super) struct ExecutionLedger { pub(super) plan: Option, #[serde(default)] pub(super) last_failed_write_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) analytics_run: Option, } struct SessionData { ledger: ExecutionLedger, + analytics_capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, + analytics_output_revision: Option, started: Instant, initial_elapsed_ms: u64, lease_started: BTreeMap, @@ -153,6 +160,8 @@ fn executor_digest(path: &Path) -> Result { pub(super) struct ExecutionSession { pub(super) root: PathBuf, + /// 本次确实新建执行账本;恢复和旧预算迁移均不构成新的用户受理。 + pub(super) newly_accepted: bool, state_path: PathBuf, _owner: File, data: Mutex, @@ -216,6 +225,16 @@ pub(crate) struct WritePermit { id: String, } impl WritePermit { + pub(super) fn record_analytics_revision( + &self, + revision: u64, + change_kind: crate::analytics::contract::ChangeKind, + files_changed_count: u64, + ) { + self.session + .record_analytics_revision(revision, change_kind, files_changed_count); + } + pub(crate) fn run(&self, write: impl FnOnce() -> Result) -> Result { let mut data = self.session.lock()?; self.session.tick_locked(&mut data)?; @@ -401,6 +420,7 @@ pub(super) async fn begin( prompt: &str, requires_contract: bool, config: DirectValidationConfig, + analytics_run: Option, ) -> Result { let root = root.to_path_buf(); let prompt_hash = hash(prompt.as_bytes()); @@ -408,13 +428,14 @@ pub(super) async fn begin( let turn = super::direct_taonier_active_invocation_id_at(&root)?; let host = crate::game_creator_runtime_config_dir() .ok_or("direct-execution-host: 需要客户端私有配置目录,CLI 请提供 --config-dir")?; - open_at( + open_with_analytics_at( &host.join("direct-executions"), &root, &turn, &prompt_hash, requires_contract, &config, + analytics_run, ) }) .await @@ -462,6 +483,26 @@ pub(super) fn open_at( request_hash: &str, requires_contract: bool, config: &DirectValidationConfig, +) -> Result, String> { + open_with_analytics_at( + host, + root, + turn, + request_hash, + requires_contract, + config, + None, + ) +} + +pub(super) fn open_with_analytics_at( + host: &Path, + root: &Path, + turn: &str, + request_hash: &str, + requires_contract: bool, + config: &DirectValidationConfig, + analytics_run: Option, ) -> Result, String> { config.validate()?; let root = root @@ -521,6 +562,7 @@ pub(super) fn open_at( }; let project_id = super::read_existing_manifest_for_project(&root)?.project_id; let is_new = existing.is_none(); + let mut newly_accepted = is_new; let mut ledger = existing.unwrap_or_else(|| ExecutionLedger { schema_version: SCHEMA.into(), client_turn_id: turn.into(), @@ -546,6 +588,7 @@ pub(super) fn open_at( delivery_reviews: 0, plan: None, last_failed_write_revision: None, + analytics_run, }); if is_new { // 只继承旧项目账本的消费量,绝不把可编辑的旧成功回执提升为宿主证据。 @@ -558,6 +601,8 @@ pub(super) fn open_at( 512 * 1024, )?; if let Some(legacy) = legacy { + newly_accepted = false; + ledger.analytics_run = None; let used = legacy["usedRuns"] .as_u64() .and_then(|n| u32::try_from(n).ok()); @@ -614,10 +659,13 @@ pub(super) fn open_at( let (changed, _) = tokio::sync::watch::channel(ledger.revision); let session = Arc::new(ExecutionSession { root, + newly_accepted, state_path, _owner: owner, data: Mutex::new(SessionData { ledger, + analytics_capture: None, + analytics_output_revision: None, started: Instant::now(), initial_elapsed_ms, lease_started: BTreeMap::new(), @@ -738,6 +786,10 @@ impl ExecutionSession { pub(super) fn cancel_flag(&self) -> Arc { Arc::clone(&self.cancellation) } + pub(super) fn was_aborted(&self) -> bool { + self.abort_requested + .load(std::sync::atomic::Ordering::Acquire) + } pub(super) fn record_delivery_review(&self) -> Result { let mut data = self.lock()?; if data.ledger.phase.is_terminal() { @@ -765,6 +817,74 @@ impl ExecutionSession { self.commit(&mut data, next)?; Ok(json!({"plan":plan,"revision":data.ledger.revision,"acceptancePassed":false})) } + pub(super) fn set_analytics_capture( + &self, + capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, + ) { + let Ok(mut data) = self.data.try_lock() else { + return; + }; + data.analytics_capture = capture.and_then(|(mut context, writer)| { + // 恢复或账号切换后仍归属于真实受理的原 run。 + context.route = data.ledger.analytics_run.as_ref()?.context.route.clone(); + Some((context, writer)) + }); + } + + pub(super) fn analytics_capture( + &self, + ) -> Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )> { + self.data.try_lock().ok()?.analytics_capture.clone() + } + + pub(super) fn record_analytics_revision( + &self, + revision: u64, + change_kind: crate::analytics::contract::ChangeKind, + files_changed_count: u64, + ) { + use crate::analytics::contract::{RevisionCreated, RevisionSource, Source}; + if files_changed_count == 0 { + return; + } + let Ok(mut data) = self.data.try_lock() else { + return; + }; + if data.ledger.analytics_run.is_none() { + return; + } + data.analytics_output_revision = + Some(data.analytics_output_revision.unwrap_or(0).max(revision)); + let capture = data.analytics_capture.clone(); + let project_id = data.ledger.project_id.clone(); + drop(data); + crate::analytics::project::revision( + capture, + &project_id, + Source::Direct, + RevisionCreated { + revision_id: revision.to_string(), + revision_source: RevisionSource::Agent, + change_kind, + files_changed_count: Some(files_changed_count), + }, + ); + } + + pub(super) fn analytics_output_revision(&self) -> Option { + self.data + .try_lock() + .ok()? + .analytics_output_revision + .map(|revision| revision.to_string()) + } + pub(super) fn snapshot(&self) -> Result { let data = self.lock()?; let mut state = data.ledger.clone(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution/tests.rs index af4503af2..3f190af4e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution/tests.rs @@ -1,5 +1,74 @@ use super::*; +fn analytics_metadata(user: &str) -> crate::analytics::run::Metadata { + use crate::analytics::contract::{Context, Route, RunSource, Source}; + crate::analytics::run::Metadata::new( + Context { + route: Route::from_identity(Some(user.into()), Some("https://example.com")), + editor_session_id: uuid::Uuid::new_v4().to_string(), + client_version: "1.0.0".into(), + }, + Source::Direct, + RunSource::UserSubmit, + ) +} + +#[test] +fn run_metadata_is_persisted_with_new_ledger_and_replay_keeps_original_identity() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + crate::init_local_game_project_at(&root, "analytics-run", "执行身份").unwrap(); + let original = analytics_metadata("A"); + let host = temp.path().join("host"); + let session = open_with_analytics_at( + &host, + &root, + "turn", + &hash(b"request"), + false, + &Default::default(), + Some(original.clone()), + ) + .unwrap(); + assert!(session.newly_accepted); + assert_eq!( + session.snapshot().unwrap().analytics_run, + Some(original.clone()) + ); + drop(session); + let replay = open_with_analytics_at( + &host, + &root, + "turn", + &hash(b"request"), + false, + &Default::default(), + Some(analytics_metadata("B")), + ) + .unwrap(); + assert!(!replay.newly_accepted); + assert_eq!(replay.snapshot().unwrap().analytics_run, Some(original)); +} + +#[test] +fn legacy_run_without_metadata_is_not_assigned_current_users_identity() { + let (temp, session) = fixture(Default::default()); + let root = session.root.clone(); + assert!(session.snapshot().unwrap().analytics_run.is_none()); + drop(session); + let replay = open_with_analytics_at( + &temp.path().join("host"), + &root, + "turn-test", + &hash(b"request"), + false, + &Default::default(), + Some(analytics_metadata("B")), + ) + .unwrap(); + assert!(replay.snapshot().unwrap().analytics_run.is_none()); +} + fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc) { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("project"); @@ -13,6 +82,7 @@ fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc BTreeMap>, + after: &BTreeMap>, +) -> Option<(crate::analytics::contract::ChangeKind, u64)> { + use crate::analytics::contract::ChangeKind; + let mut result = None; + for (path, before) in before { + let Some((before, after)) = before + .as_ref() + .zip(after.get(path).and_then(Option::as_ref)) + else { + continue; + }; + if before == after { + continue; + } + let Some(kind) = crate::analytics::project::file_change_kind(path) else { + continue; + }; + result = Some(match result { + None => (kind, 1), + Some((current, count)) => ( + if current == kind { + current + } else { + ChangeKind::Mixed + }, + count + 1, + ), + }); + } + result +} + fn run_transaction( root: &Path, parsed: codex_patch_parser::ApplyPatchArgs, @@ -319,6 +353,13 @@ fn run_transaction( None }; lease.finish(passed && !uncertain, changed, None)?; + if passed && !uncertain { + if let (Some(revision), Some((kind, count))) = + (revision, analytics_patch_changes(&before, &after)) + { + session.record_analytics_revision(revision, kind, count); + } + } Ok(json!({ "status": if passed && !uncertain { "completed" } else { "failed" }, "changedPaths": if started { changed_paths } else { BTreeSet::new() }, @@ -351,6 +392,47 @@ pub(super) async fn apply(root: &Path, arguments: &Value) -> Result)]| { + entries + .iter() + .map(|(path, value)| (path.to_string(), value.map(str::to_string))) + .collect() + }; + let before = fingerprints(&[ + ("game/a.js", Some("a")), + ("game/same.js", Some("same")), + ("game/unknown.js", None), + ("game/unreadable.js", Some("old")), + (".agent/state.json", Some("old")), + ]); + let after = fingerprints(&[ + ("game/a.js", Some("b")), + ("game/same.js", Some("same")), + ("game/unknown.js", Some("new")), + ("game/unreadable.js", None), + (".agent/state.json", Some("new")), + ]); + assert_eq!( + analytics_patch_changes(&before, &after), + Some((crate::analytics::contract::ChangeKind::Code, 1)) + ); + assert_eq!(analytics_patch_changes(&before, &before), None); + let before = fingerprints(&[ + ("game/a.js", Some("missing")), + ("assets/a.png", Some("old")), + ]); + let after = fingerprints(&[ + ("game/a.js", Some("new")), + ("assets/a.png", Some("missing")), + ]); + assert_eq!( + analytics_patch_changes(&before, &after), + Some((crate::analytics::contract::ChangeKind::Mixed, 2)) + ); + } + fn project() -> (tempfile::TempDir, PathBuf) { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("project"); @@ -415,15 +497,20 @@ mod tests { #[tokio::test] async fn bundled_patch_roundtrip_preserves_partial_failure_and_rejects_closed_turn() { let (temp, root) = project(); - let session = direct_execution::open_at( + let config = temp.path().join("analytics-config"); + let (metadata, context, writer) = + super::super::direct_tool_bridge::analytics_test_writer(&config); + let session = direct_execution::open_with_analytics_at( &temp.path().join("host"), &root, "patch-roundtrip", &format!("{:x}", Sha256::digest(b"request")), false, &direct_validation::DirectValidationConfig::default(), + Some(metadata), ) .unwrap(); + session.set_analytics_capture(Some((context.clone(), writer.clone()))); session .freeze_contract(json!({"fixture":"patch protocol only"})) .unwrap(); @@ -499,6 +586,29 @@ mod tests { "writes do not invent execution passes" ); assert!(session.snapshot().unwrap().active.is_empty()); + assert_eq!( + session.analytics_output_revision(), + None, + "text fixture files are not classified as成果" + ); + let outputs = apply(&root, &json!({"patch":"*** Begin Patch\n*** Add File: game/result.js\n+const result = 1;\n*** Add File: game/style.css\n+body { color: red; }\n*** End Patch"})).await.unwrap(); + assert_eq!(outputs["status"], "completed", "{outputs}"); + let revision = outputs["revision"].as_u64().unwrap().to_string(); + assert_eq!(session.analytics_output_revision(), Some(revision.clone())); + let partial_output = apply(&root, &json!({"patch":"*** Begin Patch\n*** Add File: game/partial.js\n+const partial = 1;\n*** Update File: game/absent.js\n@@\n-old\n+new\n*** End Patch"})).await.unwrap(); + assert_eq!(partial_output["status"], "failed"); + assert_eq!(session.analytics_output_revision(), Some(revision.clone())); + let events = super::super::direct_tool_bridge::drain_analytics_test_writer( + &config, &context, &writer, + ); + let revisions: Vec<_> = events + .iter() + .filter(|event| event["event_name"] == "project_revision_created") + .collect(); + assert_eq!(revisions.len(), 1); + assert_eq!(revisions[0]["user_id"], "A"); + assert_eq!(revisions[0]["properties"]["revision_id"], revision); + assert_eq!(revisions[0]["properties"]["files_changed_count"], 2); session.interrupt("fixture stopped".into()).unwrap(); assert!(apply( &root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 26b97678f..b7d19cda3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -4243,7 +4243,44 @@ pub(crate) async fn run_direct_browser_evidence_with_cancellation_at( advisory_interaction: bool, cancellation: Option>, ) -> Result { + run_direct_browser_evidence_with_analytics_at( + root, + evidence_root, + scenario, + advisory_interaction, + cancellation, + None, + ) + .await +} + +pub(crate) async fn run_direct_browser_evidence_with_analytics_at( + root: &Path, + evidence_root: PathBuf, + scenario: Option, + advisory_interaction: bool, + cancellation: Option>, + capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, +) -> Result { + let observation = crate::analytics::preview::prepare( + root, + capture, + crate::analytics::contract::Source::Direct, + crate::analytics::contract::PreviewSource::Agent, + ); + let (_analytics_lease, observation) = match observation { + Some((lease, observation)) => (Some(lease), Some(observation)), + None => (None, None), + }; let (preview, stop_sender) = start_local_game_preview_for_project(root)?; + if let Some(observation) = observation { + observation + .with_cancellation(cancellation.clone()) + .schedule(preview.port); + } let validation = crate::browser::validate_local_preview_in_browser_with_cancellation( BrowserValidationInput { url: preview.url, @@ -4261,6 +4298,7 @@ pub(crate) async fn run_direct_browser_evidence_with_cancellation_at( cancellation, ) .await; + drop(_analytics_lease); let _ = stop_sender.send(()); validation } @@ -4781,6 +4819,8 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( None, None, None, + None, + None, ) .await } @@ -4792,6 +4832,11 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, audit: Option<&mut DirectCodexTurnAudit>, direct_user_item: Option, + capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, + analytics_attempt_id: Option<&str>, ) -> Result { if !root.is_absolute() || !root.is_dir() { return Err("当前项目目录不存在或不是绝对路径".to_string()); @@ -4814,6 +4859,8 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( turn_emitter, audit, direct_user_item, + capture, + analytics_attempt_id, ) .await { @@ -5014,6 +5061,11 @@ async fn run_direct_game_creator_turn_inner( turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, audit: Option<&mut DirectCodexTurnAudit>, direct_user_item: Option, + capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, + analytics_attempt_id: Option<&str>, ) -> Result { let requires_contract = super::direct_delivery::requires_new_web_contract( root, @@ -5035,16 +5087,49 @@ async fn run_direct_game_creator_turn_inner( DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) })? .validation; - let execution_guard = - super::direct_execution::begin(root, prompt, requires_contract, execution_config) - .await - .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) - })?; + let analytics_run = capture.as_ref().map(|(context, _)| { + crate::analytics::run::Metadata::new( + context.clone(), + crate::analytics::contract::Source::Direct, + crate::analytics::contract::RunSource::UserSubmit, + ) + }); + let execution_guard = super::direct_execution::begin( + root, + prompt, + requires_contract, + execution_config, + analytics_run, + ) + .await + .map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?; let execution_session = execution_guard.session(); + execution_session.set_analytics_capture(capture.clone()); + let started = execution_session + .newly_accepted + .then(std::time::Instant::now); + if execution_session.newly_accepted { + if let Ok(ledger) = execution_session.snapshot() { + if let (Some((_, writer)), Some(metadata)) = (&capture, &ledger.analytics_run) { + crate::analytics::run::accepted(writer, root, &ledger.project_id, metadata); + } + } + } + // 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。 + let result: Result = async { if let Some(report) = super::direct_delivery::terminal_report(&execution_session) { return Ok(report); } + if execution_session.newly_accepted { + if let Ok(ledger) = execution_session.snapshot() { + crate::analytics::goal::accepted( + capture.clone(), + root, + &ledger.project_id, + crate::analytics::contract::Source::Direct, + ); + } + } emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); if let Some(emitter) = turn_emitter { emitter.emit("running", Some("preparing"), None, None); @@ -5334,6 +5419,118 @@ async fn run_direct_game_creator_turn_inner( })?; } Ok(visible_reply) + }.await; + if let Ok(ledger) = execution_session.snapshot() { + if let (Some(metadata), Some((end_reason, error_code))) = ( + &ledger.analytics_run, + direct_analytics_outcome( + ledger.phase, + ledger.requires_contract || ledger.contract.is_some(), + result.is_err(), + execution_session.was_aborted(), + ), + ) { + let output_revision = execution_session.analytics_output_revision(); + crate::analytics::run::direct_finished( + capture, + root, + &ledger.project_id, + metadata, + analytics_attempt_id, + crate::analytics::run::Outcome { + turn_id: Some(ledger.client_turn_id.clone()), + end_reason, + error_code, + duration_ms: started + .and_then(|start| u64::try_from(start.elapsed().as_millis()).ok()), + output_change_detected: output_revision.as_ref().map(|_| true), + revision_id: output_revision, + }, + ); + } + } + result +} + +fn direct_analytics_outcome( + phase: super::direct_execution::ExecutionPhase, + has_contract: bool, + failed: bool, + aborted: bool, +) -> Option<( + crate::analytics::contract::RunEndReason, + Option, +)> { + use super::direct_execution::ExecutionPhase; + use crate::analytics::contract::{ErrorCode, RunEndReason}; + if phase == ExecutionPhase::Interrupted || aborted { + return None; + } + if phase == ExecutionPhase::Exhausted { + return Some((RunEndReason::Failed, Some(ErrorCode::RuntimeFailed))); + } + if failed { + // Direct 当前只保留 stage 和展示错误字符串,不从正文猜 Provider 错误类别。 + return Some(( + RunEndReason::Failed, + Some(ErrorCode::RuntimeErrorUnclassified), + )); + } + if phase == ExecutionPhase::Completed || !has_contract { + return Some((RunEndReason::Finished, None)); + } + None +} + +#[cfg(test)] +mod direct_analytics_tests { + use super::*; + use crate::agent::direct_execution::ExecutionPhase; + use crate::analytics::contract::{ErrorCode, RunEndReason}; + + #[test] + fn terminal_reports_do_not_turn_exhaustion_or_cancellation_into_success() { + assert_eq!( + direct_analytics_outcome(ExecutionPhase::Exhausted, true, false, false), + Some((RunEndReason::Failed, Some(ErrorCode::RuntimeFailed))) + ); + for failed in [true, false] { + assert_eq!( + direct_analytics_outcome(ExecutionPhase::Interrupted, true, failed, false), + None + ); + assert_eq!( + direct_analytics_outcome(ExecutionPhase::Working, false, failed, true), + None + ); + } + } + + #[test] + fn complete_delivery_does_not_hide_later_projection_failure() { + assert_eq!( + direct_analytics_outcome(ExecutionPhase::Completed, true, true, false), + Some(( + RunEndReason::Failed, + Some(ErrorCode::RuntimeErrorUnclassified) + )) + ); + assert_eq!( + direct_analytics_outcome(ExecutionPhase::Completed, true, false, false), + Some((RunEndReason::Finished, None)) + ); + assert_eq!( + direct_analytics_outcome(ExecutionPhase::Working, false, false, false), + Some((RunEndReason::Finished, None)) + ); + for phase in [ + ExecutionPhase::Working, + ExecutionPhase::Draining, + ExecutionPhase::Sealing, + ] { + assert_eq!(direct_analytics_outcome(phase, true, false, false), None); + } + } } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 467b70fc1..8bddcaf17 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -34,8 +34,10 @@ pub(crate) async fn chat_with_game_creator_direct_codex( mut user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, + analytics_attempt_id: Option, attachments: Option>, ) -> Result { + let capture = crate::analytics::gui::capture_writer_context(); let root = Path::new(project_path.trim()); let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; @@ -99,6 +101,8 @@ pub(crate) async fn chat_with_game_creator_direct_codex( Some(&turn_emitter), Some(&mut audit), canonical_user_item, + capture, + analytics_attempt_id.as_deref(), ) .await { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index ee87f122e..96361cdeb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1600,6 +1600,15 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value { bridge_write_file_with_permit(root, arguments, None) } +fn bridge_file_content_changed(root: &Path, path: &str, content: &[u8]) -> Option { + crate::analytics::project::file_content_changed( + root, + path, + content, + DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES as u64, + ) +} + fn bridge_write_file_with_permit( root: &Path, arguments: &Value, @@ -1641,6 +1650,11 @@ fn bridge_write_file_with_permit( "direct-codex.file.write", )?; let lock_wait_ms = acquire_started.elapsed().as_millis(); + let analytics_change_kind = write_permit.and_then(|_| { + let kind = crate::analytics::project::file_change_kind(&path)?; + (bridge_file_content_changed(root, &path, content.as_bytes()) == Some(true)) + .then_some(kind) + }); let write_started = std::time::Instant::now(); let commit = || { let written = write_local_project_file_at(root, &path, content)?; @@ -1653,6 +1667,9 @@ fn bridge_write_file_with_permit( Some(permit) => permit.run(commit)?, None => commit()?, }; + if let (Some(permit), Some(kind)) = (write_permit, analytics_change_kind) { + permit.record_analytics_revision(revision, kind, 1); + } // 现场一次 2.6KB 写入实测 5.5 秒。只在明显偏慢时记账,正常写入不刷日志。 if lock_wait_ms + write_ms > 200 { app_log!( @@ -3473,6 +3490,82 @@ pub(in crate::agent) async fn generate_images_concurrently_for_test( .await } +#[cfg(test)] +pub(super) fn analytics_test_writer( + config: &Path, +) -> ( + crate::analytics::run::Metadata, + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, +) { + use crate::analytics::{ + contract::{Context, Route, RunSource, Source}, + run, + store::AnalyticsWriter, + }; + let mut context = Context { + route: Route::from_identity(Some("A".into()), Some("https://example.com")), + editor_session_id: uuid::Uuid::new_v4().to_string(), + client_version: "1.0.0".into(), + }; + fs::create_dir_all(config).expect("create analytics test config directory"); + let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); + context.route.user_id = Some("B".into()); + let writer = AnalyticsWriter::start(config.into(), context.editor_session_id.clone()); + (metadata, context, writer) +} + +#[cfg(test)] +pub(super) fn drain_analytics_test_writer( + config: &Path, + context: &crate::analytics::contract::Context, + writer: &crate::analytics::store::AnalyticsWriter, +) -> Vec { + use crate::analytics::contract::{EntrySource, EventData, SessionStart, Source}; + let marker = context + .capture( + EventData::EditorSessionStart(SessionStart { + entry_source: EntrySource::DirectLaunch, + first_project_id: None, + }), + None, + Source::Editor, + None, + ) + .unwrap(); + let marker_id = marker.event_id.clone(); + assert!(writer.try_record(context.route.clone(), marker, marker_id.clone())); + assert!(writer.flush()); + let batches = config + .join("analytics/instances") + .join(&context.editor_session_id) + .join("batches"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + let events: Vec = fs::read_dir(&batches) + .into_iter() + .flatten() + .flatten() + .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.')) + .filter_map(|entry| fs::read_to_string(entry.path().join("events.jsonl")).ok()) + .flat_map(|contents| { + contents + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect::>() + }) + .collect(); + if events.iter().any(|event| event["event_id"] == marker_id) { + return events; + } + assert!( + std::time::Instant::now() < deadline, + "analytics FIFO sentinel timed out" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + #[cfg(test)] mod tests { #[tokio::test] @@ -4262,6 +4355,293 @@ mod tests { assert_eq!(importability.get("assets/vector.svg"), Some(&true)); } + #[tokio::test] + async fn analytics_real_file_write_preserves_original_identity_and_failed_run_revision() { + use crate::analytics::{ + contract::{ErrorCode, RunEndReason}, + run, + }; + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("project"); + let config = temporary.path().join("config"); + let (metadata, context, writer) = analytics_test_writer(&config); + let original_capture = Some((metadata.context.clone(), writer.clone())); + let mut lifecycle = crate::analytics::gui::LifecycleFixture::start( + metadata.context.clone(), + writer.clone(), + ); + lifecycle.create_and_open(&root, "direct-analytics"); + let session = super::super::direct_execution::open_with_analytics_at( + &temporary.path().join("host"), + &root, + "analytics-write", + &format!("{:x}", Sha256::digest(b"request")), + false, + &Default::default(), + Some(metadata.clone()), + ) + .unwrap(); + session.set_analytics_capture(Some((context.clone(), writer.clone()))); + super::super::direct_delivery::register_contract( + &root, + &session, + &json!({ + "scope": "核对当前项目宿主写入的成果采集", + "changeKind": "project", + "requirements": [{"id": "analytics-output", "kind": "artifact", "path": "game/index.html"}] + }), + ) + .await + .expect("freeze a validated delivery contract before writing"); + let project_id = session.snapshot().unwrap().project_id; + run::accepted(&writer, &root, &project_id, &metadata); + crate::analytics::goal::accepted( + original_capture.clone(), + &root, + &project_id, + crate::analytics::contract::Source::Direct, + ); + let lease = session + .admit(super::super::direct_execution::EffectKind::Write, None) + .unwrap(); + let permit = lease.write_permit().unwrap(); + let arguments = json!({"path":"game/index.html", "content":"真实预览"}); + let changed = bridge_write_file_with_permit(&root, &arguments, Some(&permit)); + assert_eq!(changed["isError"], false); + let payload: Value = + serde_json::from_str(changed["content"][0]["text"].as_str().unwrap()).unwrap(); + let revision = payload["revision"].as_u64().unwrap().to_string(); + assert_eq!(session.analytics_output_revision(), Some(revision.clone())); + assert_eq!( + bridge_write_file_with_permit(&root, &arguments, Some(&permit))["isError"], + false + ); + assert_eq!( + bridge_write_file_with_permit( + &root, + &json!({"path":"../bad.js","content":"bad"}), + Some(&permit) + )["isError"], + true + ); + assert_eq!(session.analytics_output_revision(), Some(revision.clone())); + lease.finish(true, true, None).unwrap(); + let attempt = uuid::Uuid::new_v4().to_string(); + run::direct_finished( + Some((context.clone(), writer.clone())), + &root, + &project_id, + &metadata, + Some(&attempt), + run::Outcome { + turn_id: Some("analytics-write".into()), + end_reason: RunEndReason::Failed, + error_code: Some(ErrorCode::RuntimeErrorUnclassified), + duration_ms: None, + output_change_detected: Some(true), + revision_id: session.analytics_output_revision(), + }, + ); + run::settle(Some((context.clone(), writer.clone())), &attempt, false); + // 默认项目使用 npm:预览服务读取真实构建目录,夹具提供构建入口,不调用构建器。 + let served_root = crate::project_game_root(&root); + fs::create_dir_all(&served_root).unwrap(); + fs::write( + served_root.join("index.html"), + "真实预览构建", + ) + .unwrap(); + let (preview_lease, observation) = crate::analytics::preview::prepare( + &root, + original_capture.clone(), + crate::analytics::contract::Source::Editor, + crate::analytics::contract::PreviewSource::User, + ) + .unwrap(); + let (preview, stop) = crate::start_local_game_preview_for_project(&root).unwrap(); + observation.observe(preview.port).await; + let preview_revision = crate::read_game_creator_agent_runtime_project_revision(&root) + .unwrap() + .revision + .to_string(); + drop(preview_lease); + let _ = stop.send(()); + let checkpoint = crate::commands::checkpoint_with_capture_for_test( + root.to_string_lossy().into_owned(), + original_capture, + ) + .unwrap(); + lifecycle.exit(); + let events = drain_analytics_test_writer(&config, &context, &writer); + let ids: std::collections::HashSet<_> = events + .iter() + .map(|event| event["event_id"].as_str().unwrap()) + .collect(); + assert_eq!(ids.len(), events.len()); + let chain: Vec<_> = events + .iter() + .filter(|event| event["user_id"] == "A") + .collect(); + for name in [ + "editor_session_start", + "editor_focus_start", + "project_create_success", + "project_open", + "creative_task_submit", + "project_revision_created", + "agent_run_failed", + "preview_ready", + "project_save", + "editor_focus_end", + "editor_session_end", + ] { + assert_eq!( + chain + .iter() + .filter(|event| event["event_name"] == name) + .count(), + 1, + "{name}" + ); + } + for event in &chain { + assert_eq!( + event["editor_session_id"], + metadata.context.editor_session_id + ); + if !event["project_id"].is_null() { + assert_eq!(event["project_id"], project_id); + } + if !event["creative_task_id"].is_null() { + assert_eq!(event["creative_task_id"], project_id); + } + if !event["agent_run_id"].is_null() { + assert_eq!(event["agent_run_id"], metadata.run_id); + } + let name = event["event_name"].as_str().unwrap(); + if matches!( + name, + "project_create_success" + | "project_open" + | "creative_task_submit" + | "project_revision_created" + | "agent_run_failed" + | "preview_ready" + | "project_save" + ) { + assert_eq!( + event["project_id"], project_id, + "{name} must identify its project" + ); + } + if matches!( + name, + "creative_task_submit" + | "project_revision_created" + | "agent_run_failed" + | "preview_ready" + | "project_save" + ) { + assert_eq!( + event["creative_task_id"], project_id, + "{name} must identify its goal" + ); + } + if name == "agent_run_failed" { + assert_eq!(event["agent_run_id"], metadata.run_id); + assert_eq!(event["agent_turn_id"], "analytics-write"); + } + } + let save = chain + .iter() + .find(|event| event["event_name"] == "project_save") + .unwrap(); + assert_eq!(save["properties"]["save_source"], "checkpoint"); + assert!(Path::new(&checkpoint.checkpoint_path).is_dir()); + let mut digest = Sha256::new(); + digest.update(serde_json::to_vec(&metadata.context.route).unwrap()); + digest.update([0]); + digest.update(format!("{project_id}:{}:project_save", checkpoint.checkpoint_id).as_bytes()); + let checkpoint_fact = format!("{:x}", digest.finalize()); + let batches = config + .join("analytics/instances") + .join(&context.editor_session_id) + .join("batches"); + let checkpoint_fact_matches = fs::read_dir(batches) + .unwrap() + .flatten() + .filter_map(|entry| fs::read(entry.path().join("meta.json")).ok()) + .filter_map(|bytes| serde_json::from_slice::(&bytes).ok()) + .filter(|batch| batch["facts"][&checkpoint_fact] == save["event_id"]) + .count(); + assert_eq!( + checkpoint_fact_matches, 1, + "save fact must use the actual checkpoint ID" + ); + let ready = chain + .iter() + .find(|event| event["event_name"] == "preview_ready") + .unwrap(); + assert_eq!(ready["properties"]["preview_version"], preview_revision); + let focus_start = chain + .iter() + .find(|event| event["event_name"] == "editor_focus_start") + .unwrap(); + let focus_end = chain + .iter() + .find(|event| event["event_name"] == "editor_focus_end") + .unwrap(); + assert_eq!( + focus_start["properties"]["focus_interval_id"], + focus_end["properties"]["focus_interval_id"] + ); + let revisions: Vec<_> = events + .iter() + .filter(|event| event["event_name"] == "project_revision_created") + .collect(); + assert_eq!(revisions.len(), 1); + assert_eq!(revisions[0]["user_id"], "A"); + assert_eq!(revisions[0]["properties"]["revision_id"], revision); + let failed: Vec<_> = events + .iter() + .filter(|event| event["event_name"] == "agent_run_failed") + .collect(); + assert_eq!(failed.len(), 1); + assert_eq!(failed[0]["user_id"], "A"); + assert_eq!(failed[0]["properties"]["revision_id"], revision); + assert_eq!(failed[0]["properties"]["output_change_detected"], true); + } + + #[test] + fn analytics_file_comparison_requires_known_bounded_content() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + assert_eq!( + bridge_file_content_changed(root, "new.js", b"new"), + Some(true) + ); + fs::write(root.join("new.js"), b"new").unwrap(); + assert_eq!( + bridge_file_content_changed(root, "new.js", b"new"), + Some(false) + ); + assert_eq!( + bridge_file_content_changed(root, "new.js", b"changed"), + Some(true) + ); + fs::create_dir(root.join("directory.js")).unwrap(); + assert_eq!( + bridge_file_content_changed(root, "directory.js", b"new"), + None + ); + fs::write( + root.join("large.js"), + vec![0; DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES + 1], + ) + .unwrap(); + assert_eq!(bridge_file_content_changed(root, "large.js", b"new"), None); + } + #[test] fn bridge_write_file_writes_project_relative_text_without_runtime_tasks() { let temporary = tempfile::tempdir().expect("create direct write root"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs index 2c62c5b18..33b849435 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs @@ -453,12 +453,15 @@ async fn run_browser_with_budget( sequence, ), )?; - let evidence = super::direct_runtime::run_direct_browser_evidence_with_cancellation_at( + let evidence = super::direct_runtime::run_direct_browser_evidence_with_analytics_at( root, evidence_root, scenario, false, reservation.as_ref().map(|r| r.session.cancel_flag()), + reservation + .as_ref() + .and_then(|r| r.session.analytics_capture()), ) .await; let (result, passed) = match evidence { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/design_session.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/design_session.rs index 10f26a650..2b1833ef9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/design_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/design_session.rs @@ -126,6 +126,9 @@ pub(crate) struct DesignTurn { pub(crate) attempt: u32, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) model_selection: Option, + /// 仅持久化埋点关联,旧回合缺失时不补历史执行。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) analytics: Option, } /// 仅保存恢复所需的用户选择,不包含连接配置或凭据。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/contract.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/contract.rs new file mode 100644 index 000000000..2c3e38b94 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/contract.rs @@ -0,0 +1,529 @@ +//! 产品事件合同。正文和凭据不属于此模块的输入,nullable 字段仍必须显式存在。 +use chrono::{DateTime, SecondsFormat, Utc}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; + +fn required_nullable<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + +macro_rules! values { + ($name:ident { $($variant:ident),+ $(,)? }) => { + #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] + #[serde(rename_all = "snake_case")] + pub(crate) enum $name { $($variant),+ } + }; +} + +values!(Source { + Editor, + Direct, + DesignAgent, + AssetCanvas, + ResourceEditor, + UiEditor, + Manual, + System +}); +values!(Status { Success, Failed }); +values!(EntrySource { + DirectLaunch, + ProjectAssociation, + AppRestore +}); +values!(SessionEndReason { + UserExit, + AppRestart +}); +values!(FocusReason { + InitialFocus, + WindowFocus, + Restore, + AccountChange +}); +values!(BlurReason { + WindowBlur, + Minimized, + AppExit, + SystemSuspend, + AccountChange +}); +values!(CreationSource { + HomeGame, + HomeDesign, + Template, + SelectedDirectory +}); +values!(OpenSource { + Create, + Picker, + Recent, + AppRestore, + ProjectAssociation +}); +values!(AgentType { + GameAgent, + DesignAgent +}); +values!(RunSource { + UserSubmit, + UserContinue, + Clarification, + Approval, + UserRetry +}); +values!(RunEndReason { + Finished, + WaitingForUser, + WaitingForApproval, + Failed +}); +values!(ErrorCode { + ProviderAuthFailed, + ProviderRateLimited, + ProviderUnavailable, + ProviderTimeout, + ProviderInvalidResponse, + LocalIoFailed, + RuntimeFailed, + RuntimeErrorUnclassified +}); +values!(RevisionSource { + Agent, + AssetCanvas, + ResourceEditor, + UiEditor, + ManualEdit, + SystemProjection +}); +values!(ChangeKind { + Code, + Asset, + Ui, + DesignDocument, + Mixed +}); +values!(PreviewSource { + User, + Agent, + AutoRestore +}); +values!(SaveSource { + Manual, + Auto, + Checkpoint +}); +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct Route { + #[serde(deserialize_with = "required_nullable")] + pub destination_origin: Option, + #[serde(deserialize_with = "required_nullable")] + pub user_id: Option, +} + +impl Route { + pub fn from_identity(user_id: Option, api_base_url: Option<&str>) -> Self { + let destination_origin = api_base_url.and_then(|raw| { + let parsed = url::Url::parse(raw).ok()?; + (matches!(parsed.scheme(), "http" | "https") + && parsed.host_str().is_some() + && parsed.username().is_empty() + && parsed.password().is_none()) + .then(|| parsed.origin().ascii_serialization()) + }); + Self { + user_id, + destination_origin, + } + } + + pub fn validate(&self) -> bool { + optional_id(&self.user_id) + && self.destination_origin.as_ref().is_none_or(|origin| { + origin.len() <= 2048 + && Self::from_identity(None, Some(origin)) + .destination_origin + .as_ref() + == Some(origin) + }) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionStart { + pub entry_source: EntrySource, + #[serde(deserialize_with = "required_nullable")] + pub first_project_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionEnd { + pub end_reason: SessionEndReason, + #[serde(deserialize_with = "required_nullable")] + pub session_duration_ms: Option, + #[serde(deserialize_with = "required_nullable")] + pub last_project_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct FocusStart { + pub focus_interval_id: String, + pub focus_reason: FocusReason, + #[serde(deserialize_with = "required_nullable")] + pub active_project_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct FocusEnd { + pub focus_interval_id: String, + pub blur_reason: BlurReason, + #[serde(deserialize_with = "required_nullable")] + pub focus_duration_ms: Option, + #[serde(deserialize_with = "required_nullable")] + pub active_project_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ProjectCreated { + pub creation_source: CreationSource, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_template_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ProjectOpened { + pub open_source: OpenSource, + #[serde(deserialize_with = "required_nullable")] + pub is_first_open: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct EmptyProperties {} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RunFinished { + pub agent_type: AgentType, + pub run_source: RunSource, + #[serde(deserialize_with = "required_nullable")] + pub duration_ms: Option, + pub retry_index: u64, + #[serde(deserialize_with = "required_nullable")] + pub output_change_detected: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub revision_id: Option, + pub end_reason: RunEndReason, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RevisionCreated { + pub revision_id: String, + pub revision_source: RevisionSource, + pub change_kind: ChangeKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub files_changed_count: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PreviewReady { + pub preview_source: PreviewSource, + pub preview_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ready_duration_ms: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ProjectSaved { + pub save_source: SaveSource, + #[serde(skip_serializing_if = "Option::is_none")] + pub revision_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde( + tag = "event_name", + content = "properties", + rename_all = "snake_case", + deny_unknown_fields +)] +pub(crate) enum EventData { + EditorSessionStart(SessionStart), + EditorSessionEnd(SessionEnd), + EditorFocusStart(FocusStart), + EditorFocusEnd(FocusEnd), + ProjectCreateSuccess(ProjectCreated), + ProjectOpen(ProjectOpened), + CreativeTaskSubmit(EmptyProperties), + AgentRunCompleted(RunFinished), + AgentRunFailed(RunFinished), + ProjectRevisionCreated(RevisionCreated), + PreviewReady(PreviewReady), + ProjectSave(ProjectSaved), +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Event { + pub schema_version: u32, + pub event_id: String, + pub event_name: String, + pub event_time: String, + #[serde(deserialize_with = "required_nullable")] + pub user_id: Option, + pub editor_session_id: String, + #[serde(deserialize_with = "required_nullable")] + pub project_id: Option, + #[serde(deserialize_with = "required_nullable")] + pub creative_task_id: Option, + #[serde(deserialize_with = "required_nullable")] + pub agent_run_id: Option, + #[serde(deserialize_with = "required_nullable")] + pub agent_turn_id: Option, + #[serde(deserialize_with = "required_nullable")] + pub status: Option, + #[serde(deserialize_with = "required_nullable")] + pub error_code: Option, + pub source: Source, + pub client_version: String, + pub properties: Value, +} + +/// 每次操作先冻结此上下文。它不持有登录凭据,也不在后台重新读取当前账号。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct Context { + pub route: Route, + pub editor_session_id: String, + pub client_version: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct RunIdentity { + pub run_id: String, + pub turn_id: Option, + pub error_code: Option, +} + +impl Context { + pub fn capture( + &self, + data: EventData, + project_id: Option, + source: Source, + run: Option, + ) -> Result { + if !self.route.validate() { + return Err("invalid_route"); + } + let status = data.status(); + let creative_task_id = data.has_goal().then(|| project_id.clone()).flatten(); + let payload = serde_json::to_value(data).map_err(|_| "serialize_failed")?; + let event = Event { + schema_version: 1, + event_id: Uuid::new_v4().to_string(), + event_name: payload["event_name"] + .as_str() + .ok_or("invalid_event")? + .to_string(), + event_time: timestamp_now(), + user_id: self.route.user_id.clone(), + editor_session_id: self.editor_session_id.clone(), + project_id, + creative_task_id, + agent_run_id: run.as_ref().map(|r| r.run_id.clone()), + agent_turn_id: run.as_ref().and_then(|r| r.turn_id.clone()), + error_code: run.and_then(|r| r.error_code), + status, + source, + client_version: self.client_version.clone(), + properties: payload["properties"].clone(), + }; + event.validate()?; + Ok(event) + } +} + +impl EventData { + fn status(&self) -> Option { + match self { + Self::EditorFocusStart(_) | Self::EditorFocusEnd(_) => None, + Self::AgentRunFailed(_) => Some(Status::Failed), + _ => Some(Status::Success), + } + } + + fn has_goal(&self) -> bool { + matches!( + self, + Self::CreativeTaskSubmit(_) + | Self::AgentRunCompleted(_) + | Self::AgentRunFailed(_) + | Self::ProjectRevisionCreated(_) + | Self::PreviewReady(_) + | Self::ProjectSave(_) + ) + } +} + +impl Event { + pub fn data(&self) -> Result { + // 可选字段不可得时省略;显式 null 仅用于合同指定的 nullable 字段。 + let optional = match self.event_name.as_str() { + "project_create_success" => &["project_template_id"][..], + "agent_run_completed" | "agent_run_failed" | "project_save" => &["revision_id"][..], + "project_revision_created" => &["files_changed_count"][..], + "preview_ready" => &["ready_duration_ms"][..], + _ => &[][..], + }; + if optional + .iter() + .any(|key| self.properties.get(key).is_some_and(Value::is_null)) + { + return Err("invalid_optional_property"); + } + serde_json::from_value(serde_json::json!({ + "event_name": self.event_name, + "properties": self.properties, + })) + .map_err(|_| "invalid_properties") + } + + pub fn validate(&self) -> Result<(), &'static str> { + if self.schema_version != 1 + || !uuid(&self.event_id) + || !uuid(&self.editor_session_id) + || !id(&self.client_version) + || !optional_id(&self.user_id) + || !optional_id(&self.project_id) + || !optional_id(&self.creative_task_id) + || !optional_id(&self.agent_turn_id) + || !valid_time(&self.event_time) + { + return Err("invalid_envelope"); + } + let data = self.data()?; + if self.status != data.status() + || self.error_code.is_some() != matches!(data, EventData::AgentRunFailed(_)) + { + return Err("invalid_status"); + } + if data.has_goal() { + if self.project_id.is_none() || self.creative_task_id != self.project_id { + return Err("invalid_goal"); + } + } else if self.creative_task_id.is_some() { + return Err("unexpected_goal"); + } + let is_run = matches!( + data, + EventData::AgentRunCompleted(_) | EventData::AgentRunFailed(_) + ); + if is_run { + if !self.agent_run_id.as_deref().is_some_and(uuid) { + return Err("invalid_run"); + } + } else if self.agent_run_id.is_some() || self.agent_turn_id.is_some() { + return Err("unexpected_run"); + } + let editor = self.source == Source::Editor; + let agent = matches!(self.source, Source::Direct | Source::DesignAgent); + let valid = match data { + EventData::EditorSessionStart(p) => editor && p.first_project_id == self.project_id, + EventData::EditorSessionEnd(p) => { + editor && p.last_project_id == self.project_id && safe(p.session_duration_ms) + } + EventData::EditorFocusStart(p) => { + editor && uuid(&p.focus_interval_id) && p.active_project_id == self.project_id + } + EventData::EditorFocusEnd(p) => { + editor + && uuid(&p.focus_interval_id) + && p.active_project_id == self.project_id + && safe(p.focus_duration_ms) + } + EventData::ProjectCreateSuccess(p) => { + editor && self.project_id.is_some() && optional_id(&p.project_template_id) + } + EventData::ProjectOpen(_) => editor && self.project_id.is_some(), + EventData::CreativeTaskSubmit(_) => agent, + EventData::AgentRunCompleted(p) | EventData::AgentRunFailed(p) => { + agent + && ((self.source == Source::Direct) == (p.agent_type == AgentType::GameAgent)) + && ((self.status == Some(Status::Failed)) + == (p.end_reason == RunEndReason::Failed)) + && safe(p.duration_ms) + && safe(Some(p.retry_index)) + && optional_id(&p.revision_id) + } + EventData::ProjectRevisionCreated(p) => { + id(&p.revision_id) + && safe(p.files_changed_count) + && match p.revision_source { + RevisionSource::Agent => agent, + RevisionSource::AssetCanvas => self.source == Source::AssetCanvas, + RevisionSource::ResourceEditor => self.source == Source::ResourceEditor, + RevisionSource::UiEditor => self.source == Source::UiEditor, + RevisionSource::ManualEdit => self.source == Source::Manual, + RevisionSource::SystemProjection => self.source == Source::System, + } + } + EventData::PreviewReady(p) => id(&p.preview_version) && safe(p.ready_duration_ms), + EventData::ProjectSave(p) => optional_id(&p.revision_id), + }; + if valid { + Ok(()) + } else { + Err("invalid_event_fields") + } + } +} + +fn id(value: &str) -> bool { + !value.trim().is_empty() && value.len() <= 256 && !value.chars().any(char::is_control) +} +fn optional_id(value: &Option) -> bool { + value.as_deref().is_none_or(id) +} +fn uuid(value: &str) -> bool { + Uuid::parse_str(value).is_ok_and(|v| v.get_version_num() == 4 && v.to_string() == value) +} +fn safe(value: Option) -> bool { + value.is_none_or(|v| v <= MAX_SAFE_INTEGER) +} +pub(super) fn valid_time(value: &str) -> bool { + DateTime::parse_from_rfc3339(value) + .is_ok_and(|v| v.to_utc().to_rfc3339_opts(SecondsFormat::Millis, true) == value) +} + +pub(crate) fn timestamp_now() -> String { + let ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + DateTime::::from_timestamp_millis(ms.min(i64::MAX as u128) as i64) + .unwrap_or_default() + .to_rfc3339_opts(SecondsFormat::Millis, true) +} + +#[cfg(test)] +#[path = "contract_tests.rs"] +mod tests; diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/contract_tests.rs new file mode 100644 index 000000000..25eacdf5c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/contract_tests.rs @@ -0,0 +1,277 @@ +use super::*; +use serde_json::json; + +fn context() -> Context { + Context { + route: Route::from_identity(Some("123".into()), Some("https://example.com/api")), + editor_session_id: Uuid::new_v4().to_string(), + client_version: "0.1.67".into(), + } +} + +fn run() -> RunFinished { + RunFinished { + agent_type: AgentType::GameAgent, + run_source: RunSource::UserSubmit, + duration_ms: None, + retry_index: 0, + output_change_detected: None, + revision_id: None, + end_reason: RunEndReason::Finished, + } +} + +fn samples() -> Vec { + let mut failed = run(); + failed.end_reason = RunEndReason::Failed; + let values = vec![ + ( + EventData::EditorSessionStart(SessionStart { + entry_source: EntrySource::DirectLaunch, + first_project_id: None, + }), + None, + Source::Editor, + ), + ( + EventData::EditorSessionEnd(SessionEnd { + end_reason: SessionEndReason::UserExit, + session_duration_ms: Some(10), + last_project_id: None, + }), + None, + Source::Editor, + ), + ( + EventData::EditorFocusStart(FocusStart { + focus_interval_id: Uuid::new_v4().to_string(), + focus_reason: FocusReason::InitialFocus, + active_project_id: None, + }), + None, + Source::Editor, + ), + ( + EventData::EditorFocusEnd(FocusEnd { + focus_interval_id: Uuid::new_v4().to_string(), + blur_reason: BlurReason::WindowBlur, + focus_duration_ms: None, + active_project_id: None, + }), + None, + Source::Editor, + ), + ( + EventData::ProjectCreateSuccess(ProjectCreated { + creation_source: CreationSource::HomeGame, + project_template_id: None, + }), + Some("project".into()), + Source::Editor, + ), + ( + EventData::ProjectOpen(ProjectOpened { + open_source: OpenSource::Recent, + is_first_open: None, + }), + Some("project".into()), + Source::Editor, + ), + ( + EventData::CreativeTaskSubmit(EmptyProperties {}), + Some("project".into()), + Source::Direct, + ), + ( + EventData::AgentRunCompleted(run()), + Some("project".into()), + Source::Direct, + ), + ( + EventData::AgentRunFailed(failed), + Some("project".into()), + Source::Direct, + ), + ( + EventData::ProjectRevisionCreated(RevisionCreated { + revision_id: "42".into(), + revision_source: RevisionSource::UiEditor, + change_kind: ChangeKind::Ui, + files_changed_count: Some(1), + }), + Some("project".into()), + Source::UiEditor, + ), + ( + EventData::PreviewReady(PreviewReady { + preview_source: PreviewSource::User, + preview_version: "42".into(), + ready_duration_ms: None, + }), + Some("project".into()), + Source::Editor, + ), + ( + EventData::ProjectSave(ProjectSaved { + save_source: SaveSource::Checkpoint, + revision_id: Some("42".into()), + }), + Some("project".into()), + Source::Manual, + ), + ]; + values + .into_iter() + .map(|(data, project, source)| { + let identity = matches!( + data, + EventData::AgentRunCompleted(_) | EventData::AgentRunFailed(_) + ) + .then(|| RunIdentity { + run_id: Uuid::new_v4().to_string(), + turn_id: Some("real-turn".into()), + error_code: matches!(data, EventData::AgentRunFailed(_)) + .then_some(ErrorCode::RuntimeErrorUnclassified), + }); + context().capture(data, project, source, identity).unwrap() + }) + .collect() +} + +#[test] +fn all_events_round_trip_with_explicit_nullable_envelopes() { + let events = samples(); + assert_eq!(events.len(), 12); + for event in events { + let value = serde_json::to_value(&event).unwrap(); + assert_eq!(value.as_object().unwrap().len(), 15); + let decoded: Event = serde_json::from_value(value).unwrap(); + decoded.validate().unwrap(); + assert_eq!(decoded.event_id, event.event_id); + assert_eq!(decoded.event_time, event.event_time); + } +} + +#[test] +fn missing_top_level_fields_and_extra_fields_are_rejected() { + for event in samples() { + let value = serde_json::to_value(event).unwrap(); + for field in value.as_object().unwrap().keys() { + let mut missing = value.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!( + serde_json::from_value::(missing).is_err(), + "missing {field}" + ); + } + let mut extra = value; + extra["prompt"] = json!("must not be accepted"); + assert!(serde_json::from_value::(extra).is_err()); + } +} + +#[test] +fn event_properties_are_closed_and_nullable_fields_are_required() { + for event in samples() { + let mut extra = event.clone(); + extra.properties["access_token"] = json!("never collected"); + assert!(extra.validate().is_err()); + for (field, value) in event.properties.as_object().unwrap() { + if !value.is_null() { + continue; + } + let mut missing = event.clone(); + missing.properties.as_object_mut().unwrap().remove(field); + assert!( + missing.validate().is_err(), + "{} missing {field}", + event.event_name + ); + } + } + let mut nested = samples().pop().unwrap(); + nested.properties["pending_approval"] = + json!({"request_id":"a", "phase":"concept", "question":"private"}); + assert!(nested.validate().is_err()); + nested.properties["pending_approval"] = Value::Null; + nested.properties["pending_clarification"] = json!({"request_id":"a", "options":[]}); + assert!(nested.validate().is_err()); +} + +#[test] +fn inconsistent_identity_status_and_numbers_are_rejected() { + let event = samples() + .into_iter() + .find(|e| e.event_name == "agent_run_completed") + .unwrap(); + let mutate: Vec<(&str, Value)> = vec![ + ("creative_task_id", json!("other-project")), + ("project_id", Value::Null), + ("agent_run_id", json!("project")), + ("status", json!("failed")), + ("source", json!("design_agent")), + ("error_code", json!("runtime_failed")), + ("schema_version", json!(2)), + ("event_time", json!("2026-09-21T00:00:00Z")), + ("event_id", json!("not-a-uuid")), + ("user_id", json!("")), + ]; + for (key, value) in mutate { + let mut raw = serde_json::to_value(&event).unwrap(); + raw[key] = value; + let invalid: Event = serde_json::from_value(raw).unwrap(); + assert!(invalid.validate().is_err(), "{key}"); + } + for (key, value) in [ + ("duration_ms", json!(-1)), + ("retry_index", json!(MAX_SAFE_INTEGER + 1)), + ("duration_ms", json!(1.5)), + ] { + let mut invalid = event.clone(); + invalid.properties[key] = value; + assert!(invalid.validate().is_err()); + } +} + +#[test] +fn frozen_context_and_origin_do_not_inherit_new_account() { + let a = context(); + let mut b = a.clone(); + b.route = Route::from_identity( + Some("456".into()), + Some("https://other.example.com/api?token=private"), + ); + let event = a + .capture( + EventData::CreativeTaskSubmit(EmptyProperties {}), + Some("p".into()), + Source::Direct, + None, + ) + .unwrap(); + assert_eq!(event.user_id.as_deref(), Some("123")); + assert_eq!( + a.route.destination_origin.as_deref(), + Some("https://example.com") + ); + assert_eq!( + b.route.destination_origin.as_deref(), + Some("https://other.example.com") + ); + assert_eq!( + Route::from_identity(None, Some("file:///private")).destination_origin, + None + ); + assert_eq!( + Route::from_identity(None, Some("https://user:secret@example.com")).destination_origin, + None + ); + assert!( + Route { + user_id: None, + destination_origin: Some("https://example.com/api".into()) + } + .validate() + == false + ); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/design.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/design.rs new file mode 100644 index 000000000..e3c4d42c3 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/design.rs @@ -0,0 +1,55 @@ +//! 策划阶段推进按约定视为成果变化;不检查文档版本或记录工作流快照。 +use super::contract::{ChangeKind, Context, EventData, RevisionCreated, RevisionSource, Source}; +use super::store::AnalyticsWriter; + +pub(crate) struct PhaseChange { + context: Context, + writer: AnalyticsWriter, + project_id: String, + revision_id: String, +} + +impl PhaseChange { + pub(crate) fn new( + capture: Option<(Context, AnalyticsWriter)>, + project_id: &str, + session_id: &str, + previous_phase: &str, + phase: &str, + ) -> Option { + if previous_phase == phase { + return None; + } + let (context, writer) = capture?; + Some(Self { + context, + writer, + project_id: project_id.to_string(), + revision_id: format!("design:{session_id}:{phase}"), + }) + } + + pub(crate) fn revision_id(&self) -> &str { + &self.revision_id + } + + // 必须在阶段与审批命令成功持久化后调用,时间与事件 ID 在此刻生成。 + pub(crate) fn record(self) { + let key = format!( + "{}:{}:project_revision_created", + self.project_id, self.revision_id + ); + let data = EventData::ProjectRevisionCreated(RevisionCreated { + revision_id: self.revision_id, + revision_source: RevisionSource::Agent, + change_kind: ChangeKind::DesignDocument, + files_changed_count: None, + }); + if let Ok(event) = + self.context + .capture(data, Some(self.project_id), Source::DesignAgent, None) + { + self.writer.try_record(self.context.route, event, key); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/goal.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/goal.rs new file mode 100644 index 000000000..be7efedec --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/goal.rs @@ -0,0 +1,166 @@ +//! 新建项目的目标采集资格。只在后台持锁读改写,不等待业务线程。 +use super::contract::{Context, EmptyProperties, Event, EventData, Route, Source}; +use super::store::AnalyticsWriter; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +const MARKER: &str = ".agent/analytics-goal.json"; +const MAX_MARKER_BYTES: usize = 4096; + +#[derive(Serialize)] +pub(super) enum Request { + Created { + root: PathBuf, + project_id: String, + }, + Accepted { + root: PathBuf, + route: Route, + event: Event, + }, +} + +impl Request { + pub(super) fn validate(&self) -> bool { + let (root, project_id) = match self { + Self::Created { root, project_id } => (root, Some(project_id.as_str())), + Self::Accepted { root, route, event } => { + if !route.validate() + || route.user_id != event.user_id + || event.event_name != "creative_task_submit" + || event.validate().is_err() + || !matches!(event.source, Source::Direct | Source::DesignAgent) + { + return false; + } + (root, event.project_id.as_deref()) + } + }; + root.is_absolute() + && root.as_os_str().len() <= 32768 + && project_id.is_some_and(|id| { + !id.trim().is_empty() && id.len() <= 256 && !id.chars().any(char::is_control) + }) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Marker { + schema_version: u32, + project_id: String, + submitted: bool, +} + +pub(crate) fn created(writer: &AnalyticsWriter, root: &Path, project_id: &str) { + writer.try_goal(Request::Created { + root: root.into(), + project_id: project_id.into(), + }); +} + +pub(crate) fn accepted( + capture: Option<(Context, AnalyticsWriter)>, + root: &Path, + project_id: &str, + source: Source, +) { + if !matches!(source, Source::Direct | Source::DesignAgent) { + return; + } + let Some((context, writer)) = capture else { + return; + }; + let Ok(event) = context.capture( + EventData::CreativeTaskSubmit(EmptyProperties {}), + Some(project_id.into()), + source, + None, + ) else { + return; + }; + writer.try_goal(Request::Accepted { + root: root.into(), + route: context.route, + event, + }); +} + +/// 成功消费资格才返回事件;消费后队列落盘失败允许漏记。 +pub(super) fn process(request: Request) -> Result, String> { + if !request.validate() { + return Err("invalid analytics goal request".into()); + } + let (root, project_id) = match &request { + Request::Created { root, project_id } => (root, project_id.as_str()), + Request::Accepted { root, event, .. } => (root, event.project_id.as_deref().unwrap()), + }; + let Some(_lock) = + crate::agent::try_acquire_game_creator_agent_runtime_task_lock(root, "analytics-goal")? + else { + return Ok(None); + }; + let manifest_path = crate::project::resolve_local_project_path(root, ".agent/manifest.json")?; + if crate::project::read_manifest(&manifest_path)?.project_id != project_id { + return Ok(None); + } + let path = crate::project::resolve_local_project_path(root, MARKER)?; + let primary = std::fs::symlink_metadata(&path); + match &request { + Request::Created { .. } => { + // 缺失主文件但存在恢复副本仍属未知,不能重新授予资格。 + let backup = crate::agent::agent_runtime_json_sidecar_backup_path(&path); + if !matches!(primary, Err(ref e) if e.kind() == std::io::ErrorKind::NotFound) + || !matches!(std::fs::symlink_metadata(backup), Err(ref e) if e.kind() == std::io::ErrorKind::NotFound) + { + return Ok(None); + } + crate::agent::write_agent_runtime_json_sidecar_with_max_bytes( + root, + MARKER, + "埋点目标资格", + &Marker { + schema_version: 1, + project_id: project_id.into(), + submitted: false, + }, + MAX_MARKER_BYTES, + )?; + Ok(None) + } + Request::Accepted { .. } => { + // 明确要求主文件存在,不启用 sidecar 的 previous 自动回退。 + let Ok(metadata) = primary else { + return Ok(None); + }; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Ok(None); + } + let Some(mut marker): Option = + crate::agent::read_agent_runtime_json_sidecar_with_max_bytes( + root, + MARKER, + "埋点目标资格", + MAX_MARKER_BYTES, + )? + else { + return Ok(None); + }; + if marker.schema_version != 1 || marker.project_id != project_id || marker.submitted { + return Ok(None); + } + marker.submitted = true; + crate::agent::write_agent_runtime_json_sidecar_with_max_bytes( + root, + MARKER, + "埋点目标资格", + &marker, + MAX_MARKER_BYTES, + )?; + match request { + Request::Accepted { route, event, .. } => Ok(Some((route, event))), + _ => unreachable!(), + } + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs new file mode 100644 index 000000000..13caaf79f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs @@ -0,0 +1,644 @@ +//! GUI 生命周期的内存状态。仅串行捕获事实,所有文件操作交给现有后台写入器。 +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; + +struct OpenProject { + id: String, + path: String, + operation_id: String, +} + +struct Focus { + id: String, + started: Instant, +} + +struct GuiState { + context: Context, + identity_sequence: u64, + writer: AnalyticsWriter, + started: Instant, + focus: Option, + focused_window: Option, + active_project: Option, + last_project: Option, + projects: HashMap, + pending_open: HashMap, + navigation_time: HashMap, + restart: bool, + closed: bool, +} + +struct Service { + session_id: String, + version: String, + writer: AnalyticsWriter, + state: Mutex, +} + +static GUI: OnceLock = OnceLock::new(); +// 仅串行化发布服务与退出;后台等待认证状态时不占此锁。 +static EXITING: Mutex = Mutex::new(false); + +#[cfg(test)] +pub(crate) struct LifecycleFixture(GuiState); + +#[cfg(test)] +impl LifecycleFixture { + pub(crate) fn start(context: Context, writer: AnalyticsWriter) -> Self { + let mut state = GuiState::new(context, 1, writer); + state.start(); + state.windows(Some("main".into()), false, true); + Self(state) + } + + pub(crate) fn create_and_open(&mut self, root: &std::path::Path, project_id: &str) { + crate::init_local_game_project_at(root, project_id, "采集完整链路测试").unwrap(); + super::goal::created(&self.0.writer, root, project_id); + self.0.emit( + EventData::ProjectCreateSuccess(ProjectCreated { + creation_source: CreationSource::HomeGame, + project_template_id: None, + }), + Some(project_id.into()), + format!("{project_id}:project-create"), + ); + self.0.project_opened( + "main".into(), + self.0.context.clone(), + OpenProject { + id: project_id.into(), + path: root.to_string_lossy().into_owned(), + operation_id: Uuid::new_v4().to_string(), + }, + OpenSource::Create, + timestamp_now(), + ); + } + + pub(crate) fn exit(&mut self) { + self.0.exit(); + } +} + +impl GuiState { + fn new(context: Context, identity_sequence: u64, writer: AnalyticsWriter) -> Self { + Self { + context, + identity_sequence, + writer, + started: Instant::now(), + focus: None, + focused_window: None, + active_project: None, + last_project: None, + projects: HashMap::new(), + pending_open: HashMap::new(), + navigation_time: HashMap::new(), + restart: false, + closed: false, + } + } + + fn emit(&self, data: EventData, project: Option, key: String) { + if let Ok(event) = self.context.capture(data, project, Source::Editor, None) { + self.writer + .try_record(self.context.route.clone(), event, key); + } + } + + fn persist(&self) { + self.writer.try_session(SessionRecord { + schema_version: 1, + editor_session_id: self.context.editor_session_id.clone(), + route: self.context.route.clone(), + lifecycle_state: if self.closed { + LifecycleState::Closed + } else { + LifecycleState::Active + }, + focus_interval_id: self.focus.as_ref().map(|f| f.id.clone()), + updated_at: timestamp_now(), + incomplete_detected_at: None, + }); + } + + fn start(&self) { + self.emit( + EventData::EditorSessionStart(SessionStart { + entry_source: EntrySource::DirectLaunch, + first_project_id: None, + }), + None, + format!("{}:session-start", self.context.editor_session_id), + ); + self.persist(); + } + + fn begin_focus(&mut self, reason: FocusReason) { + if self.focus.is_some() || self.closed { + return; + } + let id = Uuid::new_v4().to_string(); + self.emit( + EventData::EditorFocusStart(FocusStart { + focus_interval_id: id.clone(), + focus_reason: reason, + active_project_id: self.active_project.clone(), + }), + self.active_project.clone(), + format!("{id}:focus-start"), + ); + self.focus = Some(Focus { + id, + started: Instant::now(), + }); + } + + fn end_focus(&mut self, reason: BlurReason) { + let Some(focus) = self.focus.take() else { + return; + }; + self.emit( + EventData::EditorFocusEnd(FocusEnd { + focus_interval_id: focus.id.clone(), + blur_reason: reason, + focus_duration_ms: Some(elapsed_ms(focus.started)), + active_project_id: self.active_project.clone(), + }), + self.active_project.clone(), + format!("{}:focus-end", focus.id), + ); + } + + fn windows(&mut self, focused: Option, minimized: bool, initial: bool) { + if self.closed { + return; + } + let changed = self.focused_window.is_some() != focused.is_some(); + if let Some(label) = &focused { + self.active_project = self.projects.get(label).map(|p| p.id.clone()); + if self.active_project.is_some() { + self.last_project = self.active_project.clone(); + } + } + match (self.focused_window.is_some(), focused.is_some()) { + (false, true) => self.begin_focus(if initial { + FocusReason::InitialFocus + } else { + FocusReason::WindowFocus + }), + (true, false) => self.end_focus(if minimized { + BlurReason::Minimized + } else { + BlurReason::WindowBlur + }), + _ => {} + } + // A 窗口切到 B 窗口时整体仍为前台,不生成重叠区间。 + self.focused_window = focused; + if changed { + self.persist(); + } + } + + fn identity(&mut self, route: Route, sequence: u64) { + if self.closed || sequence <= self.identity_sequence || !route.validate() { + return; + } + self.identity_sequence = sequence; + if route == self.context.route { + return; + } + self.end_focus(BlurReason::AccountChange); + // 身份切换会卸载旧工作区,旧项目不能归入新账号的前台区间。 + let left_at = timestamp_now(); + for time in self.navigation_time.values_mut() { + *time = left_at.clone(); + } + self.projects.clear(); + self.pending_open.clear(); + self.active_project = None; + self.last_project = None; + self.context.route = route; + if self.focused_window.is_some() { + self.begin_focus(FocusReason::AccountChange); + } + self.persist(); + } + + fn reserve_open(&mut self, window: &str, operation: &str, path: &str, time: &str) { + match self + .navigation_time + .get(window) + .map(|previous| time.cmp(previous.as_str())) + { + None | Some(std::cmp::Ordering::Greater) => { + self.navigation_time + .insert(window.to_string(), time.to_string()); + self.pending_open.insert( + window.to_string(), + (operation.to_string(), path.to_string()), + ); + } + Some(std::cmp::Ordering::Equal) + if self + .pending_open + .get(window) + .is_none_or(|(id, _)| id != operation) + && self + .projects + .get(window) + .is_none_or(|project| project.operation_id != operation) => + { + // 同一毫秒但投递次序不确定时保留事件,不猜当前项目归属。 + self.pending_open.remove(window); + self.projects.remove(window); + if self.focused_window.as_deref() == Some(window) { + self.active_project = None; + } + } + _ => {} + } + } + + fn project_opened( + &mut self, + window: String, + context: Context, + project: OpenProject, + source: OpenSource, + time: String, + ) { + if self.closed + || self + .projects + .get(&window) + .is_some_and(|old| old.operation_id == project.operation_id) + { + return; + } + let same_identity = context.route == self.context.route; + if let Ok(mut event) = context.capture( + EventData::ProjectOpen(ProjectOpened { + open_source: source, + is_first_open: None, // 后台依据有界的历史观察补充;未知仍保留 null。 + }), + Some(project.id.clone()), + Source::Editor, + None, + ) { + event.event_time = time; + event.event_id = project.operation_id.clone(); + if event.validate().is_err() { + return; + } + self.writer.try_record( + context.route, + event, + format!("{}:project-open", project.operation_id), + ); + } + // 身份捕获或 manifest 读取较慢时,仍记录当时已成功的事实,但不倒退当前工作区。 + if !same_identity + || self + .pending_open + .get(&window) + .is_none_or(|(id, _)| id != &project.operation_id) + { + return; + } + self.pending_open.remove(&window); + self.last_project = Some(project.id.clone()); + if self.focused_window.as_deref() == Some(&window) { + self.active_project = Some(project.id.clone()); + } + self.projects.insert(window, project); + } + + fn leave(&mut self, window: &str, expected_path: Option<&str>) { + self.navigation_time + .insert(window.to_string(), timestamp_now()); + if expected_path.is_none() + || self + .pending_open + .get(window) + .is_some_and(|(_, path)| Some(path.as_str()) == expected_path) + { + self.pending_open.remove(window); + } + if expected_path + .is_some_and(|path| self.projects.get(window).is_none_or(|p| p.path != path)) + { + return; + } + self.projects.remove(window); + if self.focused_window.as_deref() == Some(window) { + self.active_project = None; + } + } + + fn exit(&mut self) { + if self.closed { + return; + } + self.end_focus(BlurReason::AppExit); + self.emit( + EventData::EditorSessionEnd(SessionEnd { + end_reason: if self.restart { + SessionEndReason::AppRestart + } else { + SessionEndReason::UserExit + }, + session_duration_ms: Some(elapsed_ms(self.started)), + last_project_id: self.last_project.clone(), + }), + self.last_project.clone(), + format!("{}:session-end", self.context.editor_session_id), + ); + self.closed = true; + self.persist(); + self.writer.flush(); + } +} + +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); + } + }); + 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, 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()); + } +} + +fn with_state(f: impl FnOnce(&mut GuiState) -> T) -> Option { + let gui = GUI.get()?; + // 临界区只更新内存并 try_send,不做磁盘/网络操作,也不重新取得业务锁。 + // 必须串行处理身份与退出通知,不能因瞬时竞争丢失后持续使用旧身份。 + let mut state = gui.state.lock().ok()?; + Some(f(&mut state)) +} + +pub(crate) fn identity_changed(route: Route, sequence: u64) { + with_state(|state| state.identity(route, sequence)); +} + +#[tauri::command] +pub(crate) fn capture_analytics_context() -> Option { + let gui = GUI.get()?; + let (route, _) = crate::platform_session::analytics_identity_snapshot()?; + Some(Context { + route, + editor_session_id: gui.session_id.clone(), + client_version: gui.version.clone(), + }) +} + +pub(crate) fn capture_writer_context() -> Option<(Context, AnalyticsWriter)> { + Some((capture_analytics_context()?, GUI.get()?.writer.clone())) +} + +#[tauri::command] +pub(crate) fn settle_direct_run_analytics(attempt_id: String, discard: bool) { + super::run::settle(capture_writer_context(), &attempt_id, discard); +} + +fn valid_context(context: &Context) -> bool { + GUI.get().is_some_and(|gui| { + context.editor_session_id == gui.session_id + && context.client_version == gui.version + && context.route.validate() + }) +} + +pub(crate) fn created( + context: Option, + project_root: &std::path::Path, + project_id: String, + source: CreationSource, + template_id: Option, +) { + let (Some(context), Some(gui)) = (context, GUI.get()) else { + return; + }; + if !valid_context(&context) { + return; + } + super::goal::created(&gui.writer, project_root, &project_id); + if let Ok(event) = context.capture( + EventData::ProjectCreateSuccess(ProjectCreated { + creation_source: source, + project_template_id: template_id, + }), + Some(project_id.clone()), + Source::Editor, + None, + ) { + gui.writer + .try_record(context.route, event, format!("{project_id}:project-create")); + } +} + +#[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; diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/gui_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui_tests.rs new file mode 100644 index 000000000..8c584e9fd --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui_tests.rs @@ -0,0 +1,189 @@ +use super::*; +use std::fs; +use std::time::Duration; + +fn state() -> (tempfile::TempDir, GuiState) { + let dir = tempfile::tempdir().unwrap(); + let context = Context { + route: Route::from_identity(Some("A".into()), Some("https://example.com")), + editor_session_id: Uuid::new_v4().to_string(), + client_version: "1.0.0".into(), + }; + let writer = AnalyticsWriter::start(dir.path().into(), context.editor_session_id.clone()); + (dir, GuiState::new(context, 1, writer)) +} + +fn read_events(dir: &std::path::Path, session: &str, expected: usize) -> Vec { + let until = Instant::now() + Duration::from_secs(5); + loop { + let mut events = Vec::new(); + let batches = dir + .join("analytics/instances") + .join(session) + .join("batches"); + if let Ok(entries) = fs::read_dir(batches) { + for entry in entries.flatten() { + if Uuid::parse_str(&entry.file_name().to_string_lossy()).is_err() { + continue; + } + if let Ok(text) = fs::read_to_string(entry.path().join("events.jsonl")) { + events.extend( + text.lines() + .map(|line| serde_json::from_str::(line).unwrap()), + ); + } + } + } + if events.len() == expected { + return events; + } + assert!( + Instant::now() < until, + "expected {expected} events, found {}", + events.len() + ); + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn window_union_and_repeated_notifications_form_one_interval() { + let (dir, mut state) = state(); + state.start(); + state.windows(Some("main".into()), false, true); + let interval = state.focus.as_ref().unwrap().id.clone(); + state.windows(Some("launcher".into()), false, false); + state.windows(Some("launcher".into()), false, false); + assert_eq!(state.focus.as_ref().unwrap().id, interval); + state.windows(None, true, false); + state.windows(None, true, false); + state.exit(); + state.exit(); + let events = read_events(dir.path(), &state.context.editor_session_id, 4); + let end = events + .iter() + .find(|e| e.event_name == "editor_focus_end") + .unwrap(); + assert_eq!(end.properties["focus_interval_id"], interval); + assert_eq!(end.properties["blur_reason"], "minimized"); + assert_eq!( + events + .iter() + .filter(|e| e.event_name == "editor_session_end") + .count(), + 1 + ); +} + +#[test] +fn identity_change_splits_focus_but_refresh_and_stale_notice_do_not() { + let (dir, mut state) = state(); + state.start(); + state.windows(Some("main".into()), false, true); + let first = state.focus.as_ref().unwrap().id.clone(); + open( + &mut state, + "project-a", + &Uuid::new_v4().to_string(), + "2000-01-01T00:00:00.000Z", + ); + state.identity(state.context.route.clone(), 2); + assert_eq!(state.focus.as_ref().unwrap().id, first); + let route_b = Route::from_identity(Some("B".into()), Some("https://example.com")); + state.identity(route_b.clone(), 4); + state.identity( + Route::from_identity(Some("A".into()), Some("https://example.com")), + 3, + ); + assert_eq!(state.context.route, route_b); + assert!(state.projects.is_empty()); + assert!(state.active_project.is_none()); + assert_ne!(state.focus.as_ref().unwrap().id, first); + state.restart = true; + state.exit(); + let events = read_events(dir.path(), &state.context.editor_session_id, 7); + let old_end = events + .iter() + .find(|e| e.event_name == "editor_focus_end" && e.user_id.as_deref() == Some("A")) + .unwrap(); + assert_eq!(old_end.properties["blur_reason"], "account_change"); + let new_start = events + .iter() + .find(|e| e.event_name == "editor_focus_start" && e.user_id.as_deref() == Some("B")) + .unwrap(); + assert_eq!(new_start.properties["focus_reason"], "account_change"); + assert!(new_start.project_id.is_none()); + let end = events + .iter() + .find(|e| e.event_name == "editor_session_end") + .unwrap(); + assert_eq!(end.properties["end_reason"], "app_restart"); +} + +fn open(state: &mut GuiState, project_id: &str, operation: &str, time: &str) { + state.reserve_open("main", operation, project_id, time); + state.project_opened( + "main".into(), + state.context.clone(), + OpenProject { + id: project_id.into(), + path: project_id.into(), + operation_id: operation.into(), + }, + OpenSource::Recent, + time.into(), + ); +} + +#[test] +fn explicit_reopen_records_new_operation_and_duplicate_delivery_does_not() { + let (dir, mut state) = state(); + let id = Uuid::new_v4().to_string(); + open(&mut state, "project", &id, "2026-09-21T12:00:00.001Z"); + // 同一次已采纳操作的重复回执由幂等事实键抑制。 + open(&mut state, "project", &id, "2026-09-21T12:00:00.001Z"); + let next = Uuid::new_v4().to_string(); + assert_eq!(state.projects.get("main").unwrap().operation_id, id); + open(&mut state, "project", &next, "2026-09-21T12:00:00.002Z"); + state.writer.flush(); + let events = read_events(dir.path(), &state.context.editor_session_id, 2); + assert!(events.iter().any(|e| e.event_id == id)); + assert!(events.iter().any(|e| e.event_id == next)); + assert!(events[0].properties["is_first_open"].is_null()); + assert_eq!(events[1].properties["is_first_open"], false); +} + +#[test] +fn delayed_capture_or_manifest_read_does_not_restore_a_left_project() { + let (dir, mut state) = state(); + state.windows(Some("main".into()), false, true); + let old = Uuid::new_v4().to_string(); + state.reserve_open("main", &old, "old", "2026-09-21T12:00:00.001Z"); + let new = Uuid::new_v4().to_string(); + open(&mut state, "new", &new, "2026-09-21T12:00:00.002Z"); + state.project_opened( + "main".into(), + state.context.clone(), + OpenProject { + id: "old".into(), + path: "old".into(), + operation_id: old, + }, + OpenSource::Recent, + "2026-09-21T12:00:00.001Z".into(), + ); + assert_eq!(state.active_project.as_deref(), Some("new")); + state.leave("main", Some("new")); + let late = Uuid::new_v4().to_string(); + open(&mut state, "old", &late, "2000-01-01T00:00:00.000Z"); + assert!(state.active_project.is_none()); + state.writer.flush(); + let events = read_events(dir.path(), &state.context.editor_session_id, 4); + assert_eq!( + events + .iter() + .filter(|e| e.event_name == "project_open") + .count(), + 3 + ); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/mod.rs new file mode 100644 index 000000000..d15db1f0e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/mod.rs @@ -0,0 +1,10 @@ +//! 客户端产品埋点:本地采集与持久化,不发起上传。 +pub(crate) mod contract; +pub(crate) mod design; +pub(crate) mod goal; +pub(crate) mod gui; +pub(crate) mod preview; +pub(crate) mod project; +pub(crate) mod run; +pub(crate) mod session; +pub(crate) mod store; diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/preview.rs new file mode 100644 index 000000000..3b73b042b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/preview.rs @@ -0,0 +1,146 @@ +//! 正式 Web 预览的短时可访问性观察;不影响预览业务生命周期。 +use super::{ + contract::{Context, EventData, PreviewReady, PreviewSource, Source}, + store::AnalyticsWriter, +}; +use std::{ + path::{Path, PathBuf}, + sync::{Arc, Weak}, + time::{Duration, Instant}, +}; + +pub(crate) struct Lease { + _alive: Arc<()>, +} + +pub(crate) struct Observation { + alive: Weak<()>, + instance_id: String, + root: PathBuf, + entry: PathBuf, + project_id: String, + revision: u64, + context: Context, + writer: AnalyticsWriter, + source: Source, + preview_source: PreviewSource, + started: Instant, + cancellation: Option>, +} + +fn nonempty_entry(entry: &Path) -> bool { + std::fs::metadata(entry).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0) +} + +pub(crate) fn prepare( + root: &Path, + capture: Option<(Context, AnalyticsWriter)>, + source: Source, + preview_source: PreviewSource, +) -> Option<(Lease, Observation)> { + let (context, writer) = capture?; + let entry = crate::project_game_root(root).join("index.html"); + if !nonempty_entry(&entry) { + return None; + } + let project_id = crate::read_existing_manifest_for_project(root) + .ok()? + .project_id; + let revision = crate::read_game_creator_agent_runtime_project_revision(root) + .ok()? + .revision; + let alive = Arc::new(()); + let observation = Observation { + alive: Arc::downgrade(&alive), + instance_id: uuid::Uuid::new_v4().to_string(), + root: root.into(), + entry, + project_id, + revision, + context, + writer, + source, + preview_source, + started: Instant::now(), + cancellation: None, + }; + Some((Lease { _alive: alive }, observation)) +} + +impl Observation { + pub(crate) fn with_cancellation( + mut self, + cancellation: Option>, + ) -> Self { + self.cancellation = cancellation; + self + } + + fn current(&self) -> bool { + !self + .cancellation + .as_ref() + .is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Acquire)) + && self.alive.strong_count() > 0 + && self.entry == crate::project_game_root(&self.root).join("index.html") + && nonempty_entry(&self.entry) + && crate::read_game_creator_agent_runtime_project_revision(&self.root) + .is_ok_and(|revision| revision.revision == self.revision) + && crate::read_existing_manifest_for_project(&self.root) + .is_ok_and(|manifest| manifest.project_id == self.project_id) + } + + pub(crate) fn schedule(self, port: u16) { + tauri::async_runtime::spawn(self.observe(port)); + } + + pub(crate) async fn observe(self, port: u16) { + if !self.current() { + return; + } + let reachable = tokio::time::timeout(Duration::from_secs(2), async { + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .build() + .ok()?; + let mut response = client + .get(format!("http://127.0.0.1:{port}/")) + .send() + .await + .ok()?; + if !response.status().is_success() { + return None; + } + while let Some(chunk) = response.chunk().await.ok()? { + if !chunk.is_empty() { + return Some(()); + } + } + None + }) + .await; + if !matches!(reachable, Ok(Some(()))) || !self.current() { + return; + } + let event = self.context.capture( + EventData::PreviewReady(PreviewReady { + preview_source: self.preview_source, + preview_version: self.revision.to_string(), + ready_duration_ms: u64::try_from(self.started.elapsed().as_millis()).ok(), + }), + Some(self.project_id.clone()), + self.source, + None, + ); + if let Ok(event) = event { + if self.current() { + let key = format!( + "{}:{}:{}:preview_ready", + self.project_id, self.instance_id, self.revision + ); + self.writer.try_record(self.context.route, event, key); + } + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/project.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/project.rs new file mode 100644 index 000000000..1a8defebc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/project.rs @@ -0,0 +1,104 @@ +//! 正式项目成果事件。只接收宿主已提交的版本,不参与业务写入。 +use super::contract::{ChangeKind, Context, EventData, ProjectSaved, RevisionCreated, Source}; +use super::store::AnalyticsWriter; +use std::io::Read; +use std::path::Path; + +pub(crate) fn saved( + capture: Option<(Context, AnalyticsWriter)>, + project_id: &str, + source: Source, + operation_id: &str, + data: ProjectSaved, + event_time: Option<&str>, +) { + let Some((context, writer)) = capture else { + return; + }; + if let Ok(mut event) = context.capture( + EventData::ProjectSave(data), + Some(project_id.into()), + source, + None, + ) { + if let Some(time) = event_time { + event.event_time = time.into(); + } + writer.try_record( + context.route, + event, + format!("{project_id}:{operation_id}:project_save"), + ); + } +} + +pub(crate) fn file_content_changed( + root: &Path, + path: &str, + content: &[u8], + max_bytes: u64, +) -> Option { + let target = crate::resolve_local_project_path(root, path).ok()?; + match std::fs::symlink_metadata(&target) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(true), + Err(_) => return None, + Ok(_) => {} + } + let (file, metadata) = + crate::open_project_snapshot_regular_file(&target, "成果内容比较").ok()?; + if metadata.len() > max_bytes { + return None; + } + let mut before = Vec::new(); + file.take(max_bytes.saturating_add(1)) + .read_to_end(&mut before) + .ok()?; + (before.len() as u64 <= max_bytes).then(|| before != content) +} + +pub(crate) fn revision( + capture: Option<(Context, AnalyticsWriter)>, + project_id: &str, + source: Source, + data: RevisionCreated, +) { + let Some((context, writer)) = capture else { + return; + }; + let key = format!("{project_id}:{}:project_revision_created", data.revision_id); + if let Ok(event) = context.capture( + EventData::ProjectRevisionCreated(data), + Some(project_id.into()), + source, + None, + ) { + writer.try_record(context.route, event, key); + } +} + +// 只给已知成果分类;未知文件不猜字段,控制面和构建缓存不计成果。 +pub(crate) fn file_change_kind(path: &str) -> Option { + let path = path.to_ascii_lowercase(); + if crate::should_skip_project_snapshot_path(&path) + || path.split('/').any(|part| part.starts_with('.')) + { + return None; + } + if path.starts_with("design_artifacts/") { + return Some(ChangeKind::DesignDocument); + } + if path.starts_with("ui/") { + return Some(ChangeKind::Ui); + } + let extension = path.rsplit_once('.')?.1; + match extension { + "png" | "jpg" | "jpeg" | "webp" | "gif" | "svg" | "avif" | "mp3" | "wav" | "ogg" + | "flac" | "mp4" | "webm" | "glb" | "gltf" | "ttf" | "woff" | "woff2" => { + Some(ChangeKind::Asset) + } + "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" | "html" | "css" | "scss" | "json" | "vue" + | "svelte" | "gd" | "tscn" | "tres" | "cs" | "shader" | "glsl" | "wgsl" | "vert" + | "frag" | "rs" | "py" | "lua" | "cpp" | "h" | "c" | "hpp" => Some(ChangeKind::Code), + _ => None, + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs new file mode 100644 index 000000000..49673bb20 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs @@ -0,0 +1,400 @@ +//! 可观测运行序号和终态消费。双 Agent 各保留一个槽位,不保存历史或身份。 +use super::contract::{ + self, AgentType, Context, ErrorCode, Event, EventData, Route, RunEndReason, RunFinished, + RunIdentity, RunSource, Source, +}; +use super::store::AnalyticsWriter; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +const PATH: &str = ".agent/analytics-runs.json"; +const MAX_BYTES: usize = 4096; +const MAX_INTEGER: u64 = 9_007_199_254_740_991; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct Metadata { + pub context: Context, + pub run_id: String, + pub terminal_event_id: String, + pub source: Source, + pub run_source: RunSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_revision: Option, +} + +impl Metadata { + pub(crate) fn new(context: Context, source: Source, run_source: RunSource) -> Self { + Self { + context, + source, + run_source, + run_id: uuid::Uuid::new_v4().to_string(), + terminal_event_id: uuid::Uuid::new_v4().to_string(), + output_revision: None, + } + } + fn validate(&self) -> bool { + matches!(self.source, Source::Direct | Source::DesignAgent) + && uuid::Uuid::parse_str(&self.run_id).is_ok() + && uuid::Uuid::parse_str(&self.terminal_event_id).is_ok() + && self.context.route.validate() + && self.output_revision.as_deref().is_none_or(valid_id) + } +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct Outcome { + pub turn_id: Option, + pub end_reason: RunEndReason, + pub error_code: Option, + pub duration_ms: Option, + pub output_change_detected: Option, + pub revision_id: Option, +} + +#[derive(Serialize)] +pub(super) enum Request { + DirectCandidate { + attempt_id: String, + terminal: Box, + }, + Settle { + editor_session_id: String, + attempt_id: String, + discard: bool, + }, + Accepted { + root: PathBuf, + project_id: String, + metadata: Metadata, + }, + Terminal { + root: PathBuf, + project_id: String, + metadata: Metadata, + context: Context, + event_time: String, + outcome: Outcome, + }, +} + +impl Request { + pub(super) fn session_id(&self) -> &str { + match self { + Self::DirectCandidate { terminal, .. } => terminal.session_id(), + Self::Settle { + editor_session_id, .. + } => editor_session_id, + Self::Accepted { metadata, .. } => &metadata.context.editor_session_id, + Self::Terminal { context, .. } => &context.editor_session_id, + } + } + pub(super) fn validate(&self) -> bool { + match self { + Self::DirectCandidate { + attempt_id, + terminal, + } => { + return uuid::Uuid::parse_str(attempt_id).is_ok() + && matches!(terminal.as_ref(), Self::Terminal { metadata, .. } if metadata.source == Source::Direct) + && terminal.validate(); + } + Self::Settle { + editor_session_id, + attempt_id, + .. + } => { + return uuid::Uuid::parse_str(editor_session_id).is_ok() + && uuid::Uuid::parse_str(attempt_id).is_ok(); + } + _ => {} + } + let (root, project_id, metadata) = match self { + Self::Accepted { + root, + project_id, + metadata, + } => (root, project_id, metadata), + Self::Terminal { + root, + project_id, + metadata, + .. + } => (root, project_id, metadata), + _ => return false, + }; + root.is_absolute() + && root.as_os_str().len() <= 32768 + && valid_id(project_id) + && metadata.validate() + } +} + +pub(crate) fn accepted( + writer: &AnalyticsWriter, + root: &Path, + project_id: &str, + metadata: &Metadata, +) { + writer.try_run(Request::Accepted { + root: root.into(), + project_id: project_id.into(), + metadata: metadata.clone(), + }); +} + +pub(crate) fn finished( + capture: Option<(Context, AnalyticsWriter)>, + root: &Path, + project_id: &str, + metadata: &Metadata, + outcome: Outcome, +) { + let Some((mut context, writer)) = capture else { + return; + }; + // 恢复后的 GUI 会话属于新实例,但账号和目标平台仍属于原运行。 + context.route = metadata.context.route.clone(); + writer.try_run(Request::Terminal { + root: root.into(), + project_id: project_id.into(), + metadata: metadata.clone(), + context, + event_time: contract::timestamp_now(), + outcome, + }); +} + +pub(crate) fn direct_finished( + capture: Option<(Context, AnalyticsWriter)>, + root: &Path, + project_id: &str, + metadata: &Metadata, + attempt_id: Option<&str>, + outcome: Outcome, +) { + let Some(attempt_id) = attempt_id else { return }; + let Some((mut context, writer)) = capture else { + return; + }; + if metadata.source != Source::Direct { + return; + } + context.route = metadata.context.route.clone(); + writer.try_run(Request::DirectCandidate { + attempt_id: attempt_id.into(), + terminal: Box::new(Request::Terminal { + root: root.into(), + project_id: project_id.into(), + metadata: metadata.clone(), + context, + event_time: contract::timestamp_now(), + outcome, + }), + }); +} + +pub(crate) fn settle(capture: Option<(Context, AnalyticsWriter)>, attempt_id: &str, discard: bool) { + let Some((context, writer)) = capture else { + return; + }; + writer.try_run(Request::Settle { + editor_session_id: context.editor_session_id, + attempt_id: attempt_id.into(), + discard, + }); +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Slot { + run_id: String, + retry_index: u64, + terminal_consumed: bool, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct State { + schema_version: u32, + project_id: String, + retry_count: u64, + direct: Option, + design: Option, +} + +impl State { + fn slot(&mut self, source: Source) -> &mut Option { + if source == Source::Direct { + &mut self.direct + } else { + &mut self.design + } + } + fn validate(&self, project_id: &str) -> bool { + self.schema_version == 1 + && self.project_id == project_id + && self.retry_count <= MAX_INTEGER + && [&self.direct, &self.design] + .into_iter() + .flatten() + .all(|slot| { + uuid::Uuid::parse_str(&slot.run_id).is_ok() + && slot.retry_index <= self.retry_count + }) + } +} + +fn valid_id(value: &str) -> bool { + !value.trim().is_empty() && value.len() <= 256 && !value.chars().any(char::is_control) +} + +pub(super) fn process(request: Request) -> Result, String> { + if !request.validate() { + return Ok(None); + } + let (root, project_id, metadata) = match &request { + Request::Accepted { + root, + project_id, + metadata, + } + | Request::Terminal { + root, + project_id, + metadata, + .. + } => (root, project_id, metadata), + _ => return Ok(None), + }; + let Some(_lock) = + crate::agent::try_acquire_game_creator_agent_runtime_task_lock(root, "analytics-runs")? + else { + return Ok(None); + }; + let manifest_path = crate::project::resolve_local_project_path(root, ".agent/manifest.json")?; + if crate::project::read_manifest(&manifest_path)?.project_id != *project_id { + return Ok(None); + } + let path = crate::project::resolve_local_project_path(root, PATH)?; + let mut state = match std::fs::symlink_metadata(&path) { + Ok(meta) if meta.is_file() && !meta.file_type().is_symlink() => { + let Some(state): Option = + crate::agent::read_agent_runtime_json_sidecar_with_max_bytes( + root, + PATH, + "运行埋点序号", + MAX_BYTES, + )? + else { + return Ok(None); + }; + if !state.validate(project_id) { + return Ok(None); + } + state + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if !matches!(request, Request::Accepted { .. }) { + return Ok(None); + } + let backup = crate::agent::agent_runtime_json_sidecar_backup_path(&path); + if !matches!(std::fs::symlink_metadata(backup), Err(ref e) if e.kind() == std::io::ErrorKind::NotFound) + { + return Ok(None); + } + State { + schema_version: 1, + project_id: project_id.clone(), + retry_count: 0, + direct: None, + design: None, + } + } + _ => return Ok(None), + }; + let event = match &request { + Request::Accepted { .. } => { + if state + .slot(metadata.source) + .as_ref() + .is_some_and(|slot| slot.run_id == metadata.run_id) + { + return Ok(None); + } + if metadata.run_source == RunSource::UserRetry { + if state.retry_count == MAX_INTEGER { + return Ok(None); + } + state.retry_count += 1; + } + let ordinal = state.retry_count; + *state.slot(metadata.source) = Some(Slot { + run_id: metadata.run_id.clone(), + retry_index: ordinal, + terminal_consumed: false, + }); + None + } + Request::Terminal { + context, + event_time, + outcome, + .. + } => { + let Some(slot) = state.slot(metadata.source) else { + return Ok(None); + }; + if slot.run_id != metadata.run_id || slot.terminal_consumed { + return Ok(None); + } + let data = RunFinished { + agent_type: if metadata.source == Source::Direct { + AgentType::GameAgent + } else { + AgentType::DesignAgent + }, + run_source: metadata.run_source, + duration_ms: outcome.duration_ms, + retry_index: slot.retry_index, + output_change_detected: outcome.output_change_detected, + revision_id: outcome.revision_id.clone(), + end_reason: outcome.end_reason, + }; + let data = if outcome.end_reason == RunEndReason::Failed { + EventData::AgentRunFailed(data) + } else { + EventData::AgentRunCompleted(data) + }; + let Ok(mut event) = context.capture( + data, + Some(project_id.clone()), + metadata.source, + Some(RunIdentity { + run_id: metadata.run_id.clone(), + turn_id: outcome.turn_id.clone(), + error_code: outcome.error_code, + }), + ) else { + return Ok(None); + }; + event.event_id = metadata.terminal_event_id.clone(); + event.event_time = event_time.clone(); + if event.validate().is_err() { + return Ok(None); + } + slot.terminal_consumed = true; + Some((context.route.clone(), event)) + } + _ => return Ok(None), + }; + crate::agent::write_agent_runtime_json_sidecar_with_max_bytes( + root, + PATH, + "运行埋点序号", + &state, + MAX_BYTES, + )?; + Ok(event) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/session.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/session.rs new file mode 100644 index 000000000..9c86a3590 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/session.rs @@ -0,0 +1,248 @@ +//! 实例所有权与本地会话状态;仅后台 writer 访问磁盘。 +use super::contract::Route; +use super::store::{invalid_data, read_bounded, safe_metadata}; +use serde::{Deserialize, Serialize}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::Path; +use std::time::{Duration, SystemTime}; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum LifecycleState { + Active, + Closed, + Incomplete, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionRecord { + pub schema_version: u32, + pub editor_session_id: String, + pub route: Route, + pub lifecycle_state: LifecycleState, + pub focus_interval_id: Option, + pub updated_at: String, + pub incomplete_detected_at: Option, +} + +impl SessionRecord { + pub(crate) fn validate(&self) -> bool { + self.updated_at.len() <= 64 + && self + .incomplete_detected_at + .as_ref() + .is_none_or(|s| s.len() <= 64) + && self.route.user_id.as_ref().is_none_or(|s| s.len() <= 4096) + && self + .route + .destination_origin + .as_ref() + .is_none_or(|s| s.len() <= 4096) + && self.schema_version == 1 + && uuid::Uuid::parse_str(&self.editor_session_id).is_ok() + && self.route.validate() + && self + .focus_interval_id + .as_ref() + .is_none_or(|id| uuid::Uuid::parse_str(id).is_ok()) + && chrono::DateTime::parse_from_rfc3339(&self.updated_at).is_ok() + && self + .incomplete_detected_at + .as_ref() + .is_none_or(|s| chrono::DateTime::parse_from_rfc3339(s).is_ok()) + && (self.lifecycle_state == LifecycleState::Incomplete) + == self.incomplete_detected_at.is_some() + } +} + +pub(super) fn claim(instance: &Path) -> io::Result { + let path = instance.join("owner.lock"); + if path.exists() { + safe_metadata(&path)?; + } + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path)?; + file.try_lock().map_err(|_| invalid_data())?; + Ok(file) +} + +fn existing_owner(instance: &Path) -> io::Result { + let path = instance.join("owner.lock"); + if !safe_metadata(&path)?.is_file() { + return Err(invalid_data()); + } + let file = OpenOptions::new().read(true).write(true).open(path)?; + file.try_lock().map_err(|_| invalid_data())?; + Ok(file) +} + +pub(super) fn write(instance: &Path, record: &SessionRecord, session: &str) -> io::Result<()> { + if !record.validate() || record.editor_session_id != session { + return Err(invalid_data()); + } + let bytes = serde_json::to_vec(record)?; + if bytes.len() > 16 * 1024 { + return Err(invalid_data()); + } + let target = instance.join("session.json"); + if target.exists() { + safe_metadata(&target)?; + } + let temporary = instance.join(format!(".session-{}.tmp", uuid::Uuid::new_v4())); + let result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + file.write_all(&bytes)?; + file.sync_all()?; + drop(file); + fs::rename(&temporary, target) + })(); + if result.is_err() { + let _ = fs::remove_file(temporary); + } + result +} + +fn read(instance: &Path) -> io::Result { + let record: SessionRecord = + serde_json::from_slice(&read_bounded(&instance.join("session.json"), 16 * 1024)?)?; + if !record.validate() + || instance.file_name().and_then(|s| s.to_str()) != Some(record.editor_session_id.as_str()) + { + return Err(invalid_data()); + } + Ok(record) +} + +pub(super) fn recover(instances: &Path, current: &str) { + let Ok(entries) = fs::read_dir(instances) else { + return; + }; + for entry in entries.flatten() { + if entry.file_name() == current || !safe_metadata(&entry.path()).is_ok_and(|m| m.is_dir()) { + continue; + } + let Ok(_owner) = existing_owner(&entry.path()) else { + continue; + }; + let Ok(mut record) = read(&entry.path()) else { + continue; + }; + if record.lifecycle_state != LifecycleState::Active { + continue; + } + record.lifecycle_state = LifecycleState::Incomplete; + record.incomplete_detected_at = Some(super::contract::timestamp_now()); + let _ = write(&entry.path(), &record, &record.editor_session_id); + } +} + +pub(super) fn prune( + instances: &Path, + current: &str, + reserve: u64, + limit: u64, + retention: Duration, + size: impl Fn() -> u64, +) { + let Ok(entries) = fs::read_dir(instances) else { + return; + }; + let mut candidates = Vec::new(); + let mut inactive = Vec::new(); + for entry in entries.flatten() { + if entry.file_name() == current + || uuid::Uuid::parse_str(&entry.file_name().to_string_lossy()).is_err() + || !safe_metadata(&entry.path()).is_ok_and(|m| m.is_dir()) + { + continue; + } + // 文件锁是存活证明;损坏或未写完的 JSON 不能永久阻止队列清理。 + let Ok(_owner) = existing_owner(&entry.path()) else { + continue; + }; + let Ok(files) = fs::read_dir(entry.path()) else { + continue; + }; + for file in files.flatten() { + let name = file.file_name(); + let name = name.to_string_lossy(); + let temporary = name + .strip_prefix(".session-") + .and_then(|name| name.strip_suffix(".tmp")) + .is_some_and(|id| uuid::Uuid::parse_str(id).is_ok()); + if name != "session.json" && !temporary { + continue; + } + let Ok(meta) = safe_metadata(&file.path()) else { + continue; + }; + if !meta.is_file() { + continue; + } + let Ok(modified) = meta.modified() else { + continue; + }; + candidates.push((modified, entry.path(), file.path())); + } + inactive.push(entry.path()); + } + candidates.sort_by_key(|(modified, _, _)| *modified); + for (modified, instance, path) in candidates { + let expired = SystemTime::now() + .duration_since(modified) + .is_ok_and(|age| age >= retention); + if !expired && size().saturating_add(reserve) <= limit { + continue; + } + let Ok(_owner) = existing_owner(&instance) else { + continue; + }; + if safe_metadata(&path).is_ok_and(|meta| meta.is_file()) { + let _ = fs::remove_file(path); + } + } + for instance in inactive { + let Ok(_owner) = existing_owner(&instance) else { + continue; + }; + remove_empty_instance(&instance); + } +} + +fn remove_empty_instance(instance: &Path) { + // 只删除空目录及普通 owner.lock,不递归删除未知文件或跟随链接。 + let batches = instance.join("batches"); + if safe_metadata(&batches).is_ok_and(|meta| meta.is_dir()) { + let _ = fs::remove_dir(&batches); + } + let Ok(entries) = fs::read_dir(instance) else { + return; + }; + let Ok(entries) = entries.collect::, _>>() else { + return; + }; + if entries.len() != 1 || entries[0].file_name() != "owner.lock" { + return; + } + let owner = instance.join("owner.lock"); + if !safe_metadata(&owner).is_ok_and(|meta| meta.is_file()) { + return; + } + // 调用者仍持有锁;实例 UUID 不复用,其他清理者不能同时取得所有权。 + if fs::remove_file(owner).is_ok() { + let _ = fs::remove_dir(instance); + } +} + +#[cfg(test)] +#[path = "session_tests.rs"] +mod tests; diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/session_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/session_tests.rs new file mode 100644 index 000000000..fd4c84c9b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/session_tests.rs @@ -0,0 +1,101 @@ +use super::*; + +fn fixture() -> (tempfile::TempDir, std::path::PathBuf, SessionRecord) { + let dir = tempfile::tempdir().unwrap(); + let id = uuid::Uuid::new_v4().to_string(); + let instance = dir.path().join(&id); + fs::create_dir(&instance).unwrap(); + let record = SessionRecord { + schema_version: 1, + editor_session_id: id, + route: Route { + user_id: Some("user".into()), + destination_origin: Some("https://example.com".into()), + }, + lifecycle_state: LifecycleState::Active, + focus_interval_id: None, + updated_at: "2026-09-21T00:00:00.000Z".into(), + incomplete_detected_at: None, + }; + (dir, instance, record) +} + +#[test] +fn locked_owner_is_live_and_released_owner_is_recovered_once() { + let (dir, path, record) = fixture(); + let owner = claim(&path).unwrap(); + write(&path, &record, &record.editor_session_id).unwrap(); + recover(dir.path(), "another"); + assert_eq!(read(&path).unwrap().lifecycle_state, LifecycleState::Active); + drop(owner); + recover(dir.path(), "another"); + let recovered = read(&path).unwrap(); + assert_eq!(recovered.lifecycle_state, LifecycleState::Incomplete); + assert_eq!(recovered.updated_at, record.updated_at); + let bytes = fs::read(path.join("session.json")).unwrap(); + recover(dir.path(), "another"); + assert_eq!(fs::read(path.join("session.json")).unwrap(), bytes); +} + +#[test] +fn missing_proof_and_wrong_identity_are_untouched() { + let (dir, path, record) = fixture(); + write(&path, &record, &record.editor_session_id).unwrap(); + recover(dir.path(), "another"); + assert_eq!(read(&path).unwrap().lifecycle_state, LifecycleState::Active); + drop(claim(&path).unwrap()); + let mut wrong = record.clone(); + wrong.editor_session_id = uuid::Uuid::new_v4().to_string(); + fs::write( + path.join("session.json"), + serde_json::to_vec(&wrong).unwrap(), + ) + .unwrap(); + let before = fs::read(path.join("session.json")).unwrap(); + recover(dir.path(), "another"); + assert_eq!(fs::read(path.join("session.json")).unwrap(), before); +} + +#[test] +fn metadata_prune_skips_live_owner_and_removes_inactive_record() { + let (dir, path, mut record) = fixture(); + let owner = claim(&path).unwrap(); + record.lifecycle_state = LifecycleState::Closed; + write(&path, &record, &record.editor_session_id).unwrap(); + prune(dir.path(), "other", 0, 0, Duration::ZERO, || 100); + assert!(path.join("session.json").exists()); + drop(owner); + prune(dir.path(), "other", 0, 0, Duration::ZERO, || 100); + assert!(!path.join("session.json").exists()); +} + +#[test] +fn corrupt_and_abandoned_session_files_are_bounded_and_empty_instance_is_removed() { + let (dir, path, _) = fixture(); + drop(claim(&path).unwrap()); + fs::create_dir(path.join("batches")).unwrap(); + fs::write(path.join("session.json"), b"broken-json").unwrap(); + let temporary = path.join(format!(".session-{}.tmp", uuid::Uuid::new_v4())); + fs::write(&temporary, b"unfinished").unwrap(); + prune(dir.path(), "other", 0, u64::MAX, Duration::ZERO, || 100); + assert!(!path.exists()); +} + +#[test] +fn pressure_prunes_corrupt_metadata_but_keeps_live_and_unknown_files() { + let (dir, path, _) = fixture(); + let owner = claim(&path).unwrap(); + fs::write(path.join("session.json"), b"broken").unwrap(); + let temporary = path.join(format!(".session-{}.tmp", uuid::Uuid::new_v4())); + fs::write(&temporary, b"unfinished").unwrap(); + fs::write(path.join("unknown.json"), b"untouched").unwrap(); + prune(dir.path(), "other", 1, 0, Duration::MAX, || 100); + assert!(path.join("session.json").exists()); + assert!(temporary.exists()); + drop(owner); + prune(dir.path(), "other", 1, 0, Duration::MAX, || 100); + assert!(!path.join("session.json").exists()); + assert!(!temporary.exists()); + assert!(path.join("unknown.json").exists()); + assert!(path.join("owner.lock").exists()); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs new file mode 100644 index 000000000..ac14bad76 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs @@ -0,0 +1,916 @@ +//! 本地产品事件队列。业务调用只投递有界通道,磁盘工作由单独线程完成。 +use super::contract::{Event, Route}; +use super::session::{self, SessionRecord}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fs::{self, File}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{mpsc, Arc}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const MAX_EVENTS: usize = 500; +const MAX_BYTES: usize = 1024 * 1024; +const MAX_TOTAL_BYTES: u64 = 20 * 1024 * 1024; +const RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60); +const SEAL_AFTER: Duration = Duration::from_secs(5 * 60); +const QUEUE_CAPACITY: usize = 1024; +const MAX_META_BYTES: usize = 256 * 1024; + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct StoreCounters { + pub accepted: u64, + pub dropped: u64, + pub duplicate: u64, + pub corrupt_batches: u64, + pub io_errors: u64, +} + +#[derive(Default)] +struct Counters { + queued_bytes: AtomicU64, + accepted: AtomicU64, + dropped: AtomicU64, + duplicate: AtomicU64, + corrupt_batches: AtomicU64, + io_errors: AtomicU64, +} + +enum Command { + Record(Route, Event, String, u64), + Session(SessionRecord, u64), + Goal(super::goal::Request, u64), + Run(super::run::Request, u64), + Flush, +} + +#[derive(Clone)] +pub(crate) struct AnalyticsWriter { + sender: mpsc::SyncSender, + counters: Arc, +} + +impl AnalyticsWriter { + pub(crate) fn start(config_dir: PathBuf, session_id: String) -> Self { + let (sender, receiver) = mpsc::sync_channel(QUEUE_CAPACITY); + let counters = Arc::new(Counters::default()); + let worker_counters = counters.clone(); + let result = std::thread::Builder::new() + .name("local-analytics".into()) + .spawn(move || { + let mut store = match Store::open(config_dir, session_id, worker_counters.clone()) { + Ok(store) => store, + Err(_) => { + worker_counters.io_errors.fetch_add(1, Ordering::Relaxed); + return; + } + }; + loop { + if store + .started + .is_some_and(|start| start.elapsed() >= SEAL_AFTER) + { + store.seal(); + } + let wait = store + .started + .map(|start| SEAL_AFTER.saturating_sub(start.elapsed())) + .unwrap_or(SEAL_AFTER); + match receiver.recv_timeout(wait) { + Ok(Command::Record(route, event, key, bytes)) => { + worker_counters + .queued_bytes + .fetch_sub(bytes, Ordering::Relaxed); + store.record(route, event, &key); + } + Ok(Command::Run(request, bytes)) => { + worker_counters + .queued_bytes + .fetch_sub(bytes, Ordering::Relaxed); + store.run_request(request, bytes); + } + Ok(Command::Goal(request, bytes)) => { + worker_counters + .queued_bytes + .fetch_sub(bytes, Ordering::Relaxed); + // 身份校验必须发生在消费资格前。 + let valid_session = match &request { + super::goal::Request::Accepted { event, .. } => { + event.editor_session_id == store.session_id + } + _ => true, + }; + if valid_session { + match super::goal::process(request) { + Ok(Some((route, event))) => { + let fact = format!( + "{}:creative_task_submit", + event.project_id.as_deref().unwrap() + ); + store.record(route, event, &fact); + } + Ok(None) => {} + Err(_) => { + worker_counters.io_errors.fetch_add(1, Ordering::Relaxed); + } + } + } else { + worker_counters.dropped.fetch_add(1, Ordering::Relaxed); + } + } + Ok(Command::Session(record, bytes)) => { + worker_counters + .queued_bytes + .fetch_sub(bytes, Ordering::Relaxed); + if store.write_session(&record, bytes).is_err() { + worker_counters.io_errors.fetch_add(1, Ordering::Relaxed); + } + } + Ok(Command::Flush) | Err(mpsc::RecvTimeoutError::Timeout) => store.seal(), + Err(mpsc::RecvTimeoutError::Disconnected) => { + store.seal(); + break; + } + } + } + }); + if result.is_err() { + counters.io_errors.fetch_add(1, Ordering::Relaxed); + } + Self { sender, counters } + } + + /// 不等待队列、磁盘或后台线程;调用方不得将 false 转成业务错误。 + pub(crate) fn try_record(&self, route: Route, event: Event, fact_key: String) -> bool { + // 同时限制条数与字节数,巨型输入不得先占满队列再交给后台拒绝。 + let mut size = LimitedSize(1); + if fact_key.is_empty() + || fact_key.len() > 4096 + || !route.validate() + || event.validate().is_err() + || route.user_id != event.user_id + || serde_json::to_writer(&mut size, &event).is_err() + || serde_json::to_writer(&mut size, &route).is_err() + { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + let bytes = (size.0 + fact_key.len()) as u64; + if self + .counters + .queued_bytes + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + (current.saturating_add(bytes) <= MAX_BYTES as u64).then_some(current + bytes) + }) + .is_err() + { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + if self + .sender + .try_send(Command::Record(route, event, fact_key, bytes)) + .is_ok() + { + true + } else { + self.counters + .queued_bytes + .fetch_sub(bytes, Ordering::Relaxed); + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + false + } + } + + pub(crate) fn try_session(&self, record: SessionRecord) -> bool { + let mut size = LimitedSize(0); + if !record.validate() + || serde_json::to_writer(&mut size, &record).is_err() + || size.0 > 16 * 1024 + { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + let bytes = size.0 as u64; + if self + .counters + .queued_bytes + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + (current.saturating_add(bytes) <= MAX_BYTES as u64).then_some(current + bytes) + }) + .is_err() + { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + if self + .sender + .try_send(Command::Session(record, bytes)) + .is_ok() + { + true + } else { + self.counters + .queued_bytes + .fetch_sub(bytes, Ordering::Relaxed); + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + false + } + } + + pub(super) fn try_goal(&self, request: super::goal::Request) -> bool { + let mut size = LimitedSize(0); + if !request.validate() || serde_json::to_writer(&mut size, &request).is_err() { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + let bytes = size.0 as u64; + if self + .counters + .queued_bytes + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + (current.saturating_add(bytes) <= MAX_BYTES as u64).then_some(current + bytes) + }) + .is_err() + { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + if self.sender.try_send(Command::Goal(request, bytes)).is_ok() { + true + } else { + self.counters + .queued_bytes + .fetch_sub(bytes, Ordering::Relaxed); + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + false + } + } + + pub(super) fn try_run(&self, request: super::run::Request) -> bool { + let mut size = LimitedSize(0); + if !request.validate() || serde_json::to_writer(&mut size, &request).is_err() { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + let bytes = size.0 as u64; + if self + .counters + .queued_bytes + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + (current.saturating_add(bytes) <= MAX_BYTES as u64).then_some(current + bytes) + }) + .is_err() + { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + if self.sender.try_send(Command::Run(request, bytes)).is_ok() { + true + } else { + self.counters + .queued_bytes + .fetch_sub(bytes, Ordering::Relaxed); + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + false + } + } + + pub(crate) fn flush(&self) -> bool { + self.sender.try_send(Command::Flush).is_ok() + } + + pub(crate) fn counters(&self) -> StoreCounters { + StoreCounters { + accepted: self.counters.accepted.load(Ordering::Relaxed), + dropped: self.counters.dropped.load(Ordering::Relaxed), + duplicate: self.counters.duplicate.load(Ordering::Relaxed), + corrupt_batches: self.counters.corrupt_batches.load(Ordering::Relaxed), + io_errors: self.counters.io_errors.load(Ordering::Relaxed), + } + } +} + +struct LimitedSize(usize); + +impl Write for LimitedSize { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if self.0.saturating_add(bytes.len()) > MAX_BYTES { + return Err(invalid_data()); + } + self.0 += bytes.len(); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct BatchMeta { + schema_version: u32, + batch_id: String, + editor_session_id: String, + route: Route, + created_at_ms: u64, + event_count: usize, + event_ids: Vec, + facts: HashMap, +} + +struct Batch { + path: PathBuf, + modified: SystemTime, +} + +struct Store { + pending_runs: VecDeque<(String, super::run::Request, u64)>, + pending_run_bytes: u64, + root: PathBuf, + _owner: File, + projects: HashMap, + batches: PathBuf, + session_id: String, + route: Option, + events: Vec<(Event, String, Vec)>, + bytes: usize, + started: Option, + known: HashMap, + known_ids: HashSet, + counters: Arc, +} + +impl Store { + fn open(config: PathBuf, session_id: String, counters: Arc) -> io::Result { + if uuid::Uuid::parse_str(&session_id).is_err() { + return Err(invalid_data()); + } + let root = config.join("analytics"); + let instances = root.join("instances"); + let instance = instances.join(&session_id); + let batches = instance.join("batches"); + for directory in [&root, &instances, &instance, &batches] { + ensure_directory(directory)?; + } + let owner = session::claim(&instance)?; + session::recover(&instances, &session_id); + let mut store = Self { + pending_runs: VecDeque::new(), + pending_run_bytes: 0, + root, + _owner: owner, + projects: HashMap::new(), + batches, + session_id, + route: None, + events: Vec::new(), + bytes: 0, + started: None, + known: HashMap::new(), + known_ids: HashSet::new(), + counters, + }; + // session UUID 必须由 GUI 为本次新实例生成;不触碰其他实例的临时目录。 + for entry in fs::read_dir(&store.batches)? { + let entry = entry?; + if entry.file_name().to_string_lossy().starts_with(".tmp-") { + let _ = remove_batch(&entry.path()); + } + } + store.prune(0)?; + Ok(store) + } + + fn run_request(&mut self, request: super::run::Request, bytes: u64) { + use super::run::Request; + if !request.validate() || request.session_id() != self.session_id { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return; + } + let request = match request { + Request::DirectCandidate { + attempt_id, + terminal, + } => { + if bytes > MAX_BYTES as u64 { + return; + } + if self.pending_runs.iter().any(|(id, _, _)| id == &attempt_id) { + return; + } + while self.pending_runs.len() >= 16 + || self.pending_run_bytes + bytes > MAX_BYTES as u64 + { + let Some((_, _, removed)) = self.pending_runs.pop_front() else { + break; + }; + self.pending_run_bytes -= removed; + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + } + self.pending_run_bytes += bytes; + self.pending_runs.push_back((attempt_id, *terminal, bytes)); + return; + } + Request::Settle { + attempt_id, + discard, + .. + } => { + let Some(index) = self + .pending_runs + .iter() + .position(|(id, _, _)| id == &attempt_id) + else { + return; + }; + let (_, terminal, removed) = self.pending_runs.remove(index).unwrap(); + self.pending_run_bytes -= removed; + if discard { + return; + } + terminal + } + other => other, + }; + match super::run::process(request) { + Ok(Some((route, event))) => { + let fact = format!("{}:terminal", event.agent_run_id.as_deref().unwrap()); + self.record(route, event, &fact); + } + Ok(None) => {} + Err(_) => { + self.counters.io_errors.fetch_add(1, Ordering::Relaxed); + } + } + } + + fn write_session(&mut self, record: &SessionRecord, bytes: u64) -> io::Result<()> { + // 原子替换期间旧文件与临时文件同时存在,预留完整新文件大小。 + self.prune(bytes)?; + session::write(self.batches.parent().unwrap(), record, &self.session_id) + } + + fn recover(&mut self) { + let Ok(batches) = list_batches(&self.root) else { + return; + }; + for batch in batches { + match read_batch(&batch.path) { + Ok(meta) => { + if meta.facts.iter().any(|(key, id)| { + self.known.contains_key(key) || self.known_ids.contains(id) + }) { + self.counters + .corrupt_batches + .fetch_add(1, Ordering::Relaxed); + continue; + } + if let Ok(events) = read_events(&batch.path) { + for event in events { + observe_project(&mut self.projects, &meta.route, &event); + } + } + self.known_ids.extend(meta.event_ids); + self.known.extend(meta.facts); + } + Err(_) => { + // 已发布批次不重写、不重新生成事件;其他实例的文件只读跳过。 + self.counters + .corrupt_batches + .fetch_add(1, Ordering::Relaxed); + } + } + } + } + + fn record(&mut self, route: Route, mut event: Event, fact: &str) { + if event.validate().is_err() + || !route.validate() + || route.user_id != event.user_id + || event.editor_session_id != self.session_id + || fact.is_empty() + || fact.len() > 4096 + { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return; + } + let digest = fact_digest(&route, fact); + if self.known.contains_key(&digest) || self.events.iter().any(|(_, key, _)| key == &digest) + { + self.counters.duplicate.fetch_add(1, Ordering::Relaxed); + return; + } + if self.known_ids.contains(&event.event_id) + || self + .events + .iter() + .any(|(old, _, _)| old.event_id == event.event_id) + { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return; + } + if event.event_name == "project_open" { + if let Some(project) = &event.project_id { + let key = fact_digest(&route, project); + let mut opened = self.projects.get(&key).copied(); + if self.route.as_ref() == Some(&route) { + for (pending, _, _) in &self.events { + if pending.project_id.as_ref() != Some(project) { + continue; + } + match pending.event_name.as_str() { + "project_open" => opened = Some(true), + "project_create_success" if opened.is_none() => opened = Some(false), + _ => {} + } + } + } + event.properties["is_first_open"] = opened.map(|opened| !opened).into(); + } + } + if event.validate().is_err() { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return; + } + let Ok(mut line) = serde_json::to_vec(&event) else { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return; + }; + line.push(b'\n'); + if line.len() > MAX_BYTES { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return; + } + if self.route.as_ref().is_some_and(|current| current != &route) + || self.bytes + line.len() > MAX_BYTES + || self + .started + .is_some_and(|start| start.elapsed() >= SEAL_AFTER) + { + self.seal(); + } + self.route = Some(route); + self.started.get_or_insert_with(Instant::now); + self.bytes += line.len(); + self.events.push((event, digest, line)); + self.counters.accepted.fetch_add(1, Ordering::Relaxed); + if self.events.len() >= MAX_EVENTS || self.bytes >= MAX_BYTES { + self.seal(); + } + } + + fn seal(&mut self) { + if self.events.is_empty() { + if self.prune(0).is_err() { + self.counters.io_errors.fetch_add(1, Ordering::Relaxed); + } + return; + } + let events = std::mem::take(&mut self.events); + let route = self.route.take().expect("nonempty batch has route"); + self.started = None; + self.bytes = 0; + let id = uuid::Uuid::new_v4().to_string(); + let meta = BatchMeta { + schema_version: 1, + batch_id: id.clone(), + editor_session_id: self.session_id.clone(), + route, + created_at_ms: now_ms(), + event_count: events.len(), + event_ids: events + .iter() + .map(|(event, _, _)| event.event_id.clone()) + .collect(), + facts: events + .iter() + .map(|(event, key, _)| (key.clone(), event.event_id.clone())) + .collect(), + }; + let temporary = self.batches.join(format!(".tmp-{id}")); + let result = (|| -> io::Result<()> { + let meta_bytes = serde_json::to_vec(&meta)?; + if meta_bytes.len() > MAX_META_BYTES { + return Err(invalid_data()); + } + let event_bytes: usize = events.iter().map(|(_, _, line)| line.len()).sum(); + self.prune((event_bytes + meta_bytes.len()) as u64)?; + fs::create_dir(&temporary)?; + let mut file = File::create(temporary.join("events.jsonl"))?; + for (_, _, line) in &events { + file.write_all(line)?; + } + file.sync_all()?; + let mut metadata = File::create(temporary.join("meta.json"))?; + metadata.write_all(&meta_bytes)?; + metadata.sync_all()?; + drop(file); + drop(metadata); + fs::rename(&temporary, self.batches.join(&id))?; + Ok(()) + })(); + if result.is_ok() { + for (event, _, _) in &events { + observe_project(&mut self.projects, &meta.route, event); + } + self.known_ids.extend(meta.event_ids); + self.known.extend(meta.facts); + } else { + let _ = remove_batch(&temporary); + self.counters.io_errors.fetch_add(1, Ordering::Relaxed); + self.counters + .dropped + .fetch_add(events.len() as u64, Ordering::Relaxed); + } + } + + fn prune(&mut self, reserve: u64) -> io::Result<()> { + let result = self.prune_files(reserve); + // 其他实例也能清理封存批次;内存索引不能比持久批次活得更久。 + self.projects.clear(); + self.known.clear(); + self.known_ids.clear(); + self.recover(); + result + } + + fn prune_files(&mut self, reserve: u64) -> io::Result<()> { + session::prune( + &self.root.join("instances"), + &self.session_id, + reserve, + MAX_TOTAL_BYTES, + RETENTION, + || directory_size(&self.root, 0, &self.counters), + ); + let mut bytes = directory_size(&self.root, 0, &self.counters); + let mut batches = list_batches(&self.root)?; + batches.sort_by_key(|batch| batch.modified); + for batch in batches { + let expired = SystemTime::now() + .duration_since(batch.modified) + .is_ok_and(|age| age >= RETENTION); + if !expired && bytes.saturating_add(reserve) <= MAX_TOTAL_BYTES { + continue; + } + let size = directory_size(&batch.path, 0, &self.counters); + let meta = read_batch(&batch.path).ok(); + if remove_batch(&batch.path).is_err() { + self.counters.io_errors.fetch_add(1, Ordering::Relaxed); + continue; + } + bytes = bytes.saturating_sub(size); + if let Some(meta) = meta { + self.counters + .dropped + .fetch_add(meta.event_count as u64, Ordering::Relaxed); + } + } + if bytes.saturating_add(reserve) > MAX_TOTAL_BYTES { + return Err(invalid_data()); + } + Ok(()) + } +} + +pub(super) fn invalid_data() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "invalid analytics data") +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64 +} + +fn fact_digest(route: &Route, fact: &str) -> String { + let mut hash = Sha256::new(); + hash.update(serde_json::to_vec(route).expect("route contains serializable strings")); + hash.update([0]); + hash.update(fact.as_bytes()); + format!("{:x}", hash.finalize()) +} + +pub(super) fn safe_metadata(path: &Path) -> io::Result { + let meta = fs::symlink_metadata(path)?; + if meta.file_type().is_symlink() { + return Err(invalid_data()); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if meta.file_attributes() & 0x400 != 0 { + return Err(invalid_data()); + } + } + Ok(meta) +} + +fn ensure_directory(path: &Path) -> io::Result<()> { + match fs::create_dir(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + if safe_metadata(path)?.is_dir() { + Ok(()) + } else { + Err(invalid_data()) + } + } + Err(error) => Err(error), + } +} + +pub(super) fn read_bounded(path: &Path, limit: usize) -> io::Result> { + let meta = safe_metadata(path)?; + if !meta.is_file() || meta.len() > limit as u64 { + return Err(invalid_data()); + } + let mut bytes = Vec::new(); + File::open(path)? + .take(limit as u64 + 1) + .read_to_end(&mut bytes)?; + if bytes.len() > limit { + return Err(invalid_data()); + } + Ok(bytes) +} + +fn read_batch(path: &Path) -> io::Result { + if !safe_metadata(path)?.is_dir() { + return Err(invalid_data()); + } + let meta: BatchMeta = + serde_json::from_slice(&read_bounded(&path.join("meta.json"), MAX_META_BYTES)?)?; + if meta.schema_version != 1 + || !meta.route.validate() + || meta.event_count == 0 + || meta.event_count > MAX_EVENTS + || meta.event_count != meta.event_ids.len() + || meta.event_count != meta.facts.len() + || path.file_name().and_then(|name| name.to_str()) != Some(meta.batch_id.as_str()) + || path + .parent() + .and_then(Path::parent) + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + != Some(meta.editor_session_id.as_str()) + || uuid::Uuid::parse_str(&meta.batch_id).is_err() + { + return Err(invalid_data()); + } + let bytes = read_bounded(&path.join("events.jsonl"), MAX_BYTES)?; + if bytes.last() != Some(&b'\n') { + return Err(invalid_data()); + } + let mut ids = HashSet::new(); + for (index, line) in bytes[..bytes.len() - 1] + .split(|byte| *byte == b'\n') + .enumerate() + { + let event: Event = serde_json::from_slice(line)?; + if event.validate().is_err() + || event.user_id != meta.route.user_id + || event.editor_session_id != meta.editor_session_id + || meta.event_ids.get(index) != Some(&event.event_id) + || !ids.insert(event.event_id) + { + return Err(invalid_data()); + } + } + if ids.len() != meta.event_count + || meta.facts.iter().any(|(key, id)| { + key.len() != 64 || !key.bytes().all(|c| c.is_ascii_hexdigit()) || !ids.contains(id) + }) + || meta.facts.values().collect::>().len() != ids.len() + { + return Err(invalid_data()); + } + Ok(meta) +} + +fn list_batches(root: &Path) -> io::Result> { + let mut batches = Vec::new(); + let instances = root.join("instances"); + if !safe_metadata(&instances)?.is_dir() { + return Err(invalid_data()); + } + for instance in fs::read_dir(instances)? { + let Ok(instance) = instance else { continue }; + if !safe_metadata(&instance.path()).is_ok_and(|meta| meta.is_dir()) { + continue; + } + let path = instance.path().join("batches"); + if !safe_metadata(&path).is_ok_and(|meta| meta.is_dir()) { + continue; + } + let Ok(entries) = fs::read_dir(path) else { + continue; + }; + for batch in entries { + let Ok(batch) = batch else { continue }; + // 临时、隔离和未知目录不能被当成可上传批次,也不能猜其写入者已退出。 + if uuid::Uuid::parse_str(&batch.file_name().to_string_lossy()).is_err() { + continue; + } + if let Ok(meta) = safe_metadata(&batch.path()) { + if meta.is_dir() { + if batches.len() >= 65536 { + return Err(invalid_data()); + } + batches.push(Batch { + path: batch.path(), + modified: meta.modified()?, + }); + } + } + } + } + Ok(batches) +} + +fn directory_size(path: &Path, depth: usize, counters: &Counters) -> u64 { + if depth > 8 { + counters.corrupt_batches.fetch_add(1, Ordering::Relaxed); + return 0; + } + let meta = match safe_metadata(path) { + Ok(meta) => meta, + Err(_) => { + counters.corrupt_batches.fetch_add(1, Ordering::Relaxed); + // 链接只计链接本身的元数据,不打开或跟随其目标。 + return fs::symlink_metadata(path) + .map(|meta| meta.len()) + .unwrap_or(0); + } + }; + if meta.is_file() { + return meta.len(); + } + if !meta.is_dir() { + return 0; + } + let mut bytes = 0u64; + let Ok(entries) = fs::read_dir(path) else { + counters.io_errors.fetch_add(1, Ordering::Relaxed); + return 0; + }; + for entry in entries { + if let Ok(entry) = entry { + bytes = bytes.saturating_add(directory_size(&entry.path(), depth + 1, counters)); + } else { + counters.io_errors.fetch_add(1, Ordering::Relaxed); + } + } + bytes +} + +fn remove_batch(path: &Path) -> io::Result<()> { + if !safe_metadata(path)?.is_dir() { + return Err(invalid_data()); + } + let files = fs::read_dir(path)?.collect::, _>>()?; + // 删除范围严格停留在已核实的批次普通文件,绝不递归穿过未知目录或链接。 + for file in &files { + if !safe_metadata(&file.path())?.is_file() { + return Err(invalid_data()); + } + } + for file in files { + fs::remove_file(file.path())?; + } + fs::remove_dir(path) +} + +#[cfg(test)] +#[path = "store_tests.rs"] +mod tests; + +fn read_events(path: &Path) -> io::Result> { + let bytes = read_bounded(&path.join("events.jsonl"), MAX_BYTES)?; + bytes + .split(|b| *b == b'\n') + .filter(|line| !line.is_empty()) + .map(|line| serde_json::from_slice(line).map_err(io::Error::from)) + .collect() +} + +fn observe_project(projects: &mut HashMap, route: &Route, event: &Event) { + let Some(project) = &event.project_id else { + return; + }; + let key = fact_digest(route, project); + match event.event_name.as_str() { + "project_open" => { + projects.insert(key, true); + } + "project_create_success" => { + projects.entry(key).or_insert(false); + } + _ => {} + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs new file mode 100644 index 000000000..37db115dc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs @@ -0,0 +1,971 @@ +use super::*; +use serde_json::json; + +fn route(user: &str) -> Route { + Route { + user_id: Some(user.into()), + destination_origin: Some("https://example.com".into()), + } +} + +fn event(session: &str, user: &str) -> Event { + serde_json::from_value(json!({ + "schema_version": 1, + "event_id": uuid::Uuid::new_v4().to_string(), + "event_name": "editor_session_start", + "event_time": "2026-09-21T00:00:00.000Z", + "user_id": user, + "editor_session_id": session, + "project_id": null, "creative_task_id": null, + "agent_run_id": null, "agent_turn_id": null, + "status": "success", "error_code": null, "source": "editor", "client_version": "1.0.0", + "properties": { "entry_source": "direct_launch", "first_project_id": null } + })) + .unwrap() +} + +fn open(config: &Path) -> Store { + Store::open( + config.to_path_buf(), + uuid::Uuid::new_v4().to_string(), + Arc::new(Counters::default()), + ) + .unwrap() +} + +#[test] +fn buffered_and_published_replay_keep_original_identity() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + let first = event(&store.session_id, "A"); + let original_id = first.event_id.clone(); + store.record(route("A"), first, "start-original-session"); + store.record( + route("A"), + event(&store.session_id, "A"), + "start-original-session", + ); + assert_eq!(store.events.len(), 1); + assert!(list_batches(&store.root).unwrap().is_empty()); + store.seal(); + let batches = list_batches(&store.root).unwrap(); + assert_eq!(batches.len(), 1); + assert_eq!( + read_batch(&batches[0].path).unwrap().event_ids, + [original_id.clone()] + ); + let mut restored = open(dir.path()); + restored.record( + route("A"), + event(&restored.session_id, "A"), + "start-original-session", + ); + assert!(restored.events.is_empty()); + assert_eq!(restored.known.values().next(), Some(&original_id)); + assert_eq!(restored.counters.duplicate.load(Ordering::Relaxed), 1); +} + +#[test] +fn account_switch_and_count_limit_publish_complete_separate_batches() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + store.record(route("A"), event(&store.session_id, "A"), "A"); + store.record(route("B"), event(&store.session_id, "B"), "B"); + let batches = list_batches(&store.root).unwrap(); + assert_eq!(read_batch(&batches[0].path).unwrap().route, route("A")); + for index in 1..MAX_EVENTS { + store.record( + route("B"), + event(&store.session_id, "B"), + &format!("B-{index}"), + ); + } + assert!(store.events.is_empty()); + let batches = list_batches(&store.root).unwrap(); + assert_eq!(batches.len(), 2); + assert_eq!( + batches + .iter() + .map(|batch| read_batch(&batch.path).unwrap().event_count) + .sum::(), + 501 + ); + assert!(fs::read_dir(&store.batches).unwrap().all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with('.'))); +} + +#[test] +fn deadline_and_size_rotate_without_empty_files() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + store.seal(); + assert!(list_batches(&store.root).unwrap().is_empty()); + store.record(route("A"), event(&store.session_id, "A"), "first"); + store.started = Some(Instant::now() - SEAL_AFTER); + store.record(route("A"), event(&store.session_id, "A"), "next"); + assert_eq!(list_batches(&store.root).unwrap().len(), 1); + store.bytes = MAX_BYTES; + store.record(route("A"), event(&store.session_id, "A"), "third"); + assert_eq!(list_batches(&store.root).unwrap().len(), 2); + let mut huge = event(&store.session_id, "A"); + huge.client_version = "x".repeat(MAX_BYTES + 1); + store.record(route("A"), huge, "oversized"); + assert_eq!(store.events.len(), 1); + assert_eq!(store.counters.dropped.load(Ordering::Relaxed), 1); +} + +#[test] +fn corrupt_batch_does_not_rewrite_valid_batches_or_other_instance_temporary_files() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + store.record(route("A"), event(&store.session_id, "A"), "good"); + store.seal(); + let good = list_batches(&store.root).unwrap().pop().unwrap().path; + let original = fs::read(good.join("events.jsonl")).unwrap(); + store.record(route("A"), event(&store.session_id, "A"), "bad"); + store.seal(); + let bad = list_batches(&store.root) + .unwrap() + .into_iter() + .find(|batch| batch.path != good) + .unwrap() + .path; + fs::write(bad.join("events.jsonl"), b"{truncated").unwrap(); + let temporary = store.batches.join(".tmp-other-writer"); + fs::create_dir(&temporary).unwrap(); + fs::write(temporary.join("events.jsonl"), b"in progress").unwrap(); + let restored = open(dir.path()); + assert_eq!(restored.known.len(), 1); + assert_eq!(restored.counters.corrupt_batches.load(Ordering::Relaxed), 1); + assert_eq!(fs::read(good.join("events.jsonl")).unwrap(), original); + assert!(temporary.exists()); + assert!(bad.exists()); +} + +#[test] +fn retention_counts_corrupt_files_and_removes_expired_sealed_batches() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + store.record(route("A"), event(&store.session_id, "A"), "old"); + store.seal(); + let old = list_batches(&store.root).unwrap().pop().unwrap().path; + // 发布目录 mtime 是封存时间;不需要等待一周。 + let old_time = SystemTime::now() - RETENTION - Duration::from_secs(1); + #[cfg(not(windows))] + File::open(&old) + .unwrap() + .set_times(fs::FileTimes::new().set_modified(old_time)) + .unwrap(); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .custom_flags(0x02000000) + .open(&old) + .unwrap() + .set_times(fs::FileTimes::new().set_modified(old_time)) + .unwrap(); + } + store.prune(0).unwrap(); + assert!(!old.exists()); + assert!(store.known.is_empty()); + let corrupt = store.batches.join(uuid::Uuid::new_v4().to_string()); + fs::create_dir(&corrupt).unwrap(); + File::create(corrupt.join("events.jsonl")) + .unwrap() + .set_len(MAX_TOTAL_BYTES + 1) + .unwrap(); + store.prune(0).unwrap(); + assert!(!corrupt.exists()); +} + +#[test] +fn full_or_disconnected_channel_never_waits_and_counts_drops() { + let (sender, receiver) = mpsc::sync_channel(1); + let writer = AnalyticsWriter { + sender, + counters: Arc::new(Counters::default()), + }; + let session = uuid::Uuid::new_v4().to_string(); + assert!(writer.try_record(route("A"), event(&session, "A"), "first".into())); + let before = Instant::now(); + assert!(!writer.try_record(route("A"), event(&session, "A"), "full".into())); + assert!(before.elapsed() < Duration::from_secs(1)); + drop(receiver); + assert!(!writer.try_record(route("A"), event(&session, "A"), "closed".into())); + assert_eq!(writer.counters().dropped, 2); +} + +#[test] +fn disk_failure_drops_only_current_batch_without_returning_business_error() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + fs::remove_dir(&store.batches).unwrap(); + fs::write(&store.batches, b"not a directory").unwrap(); + store.record(route("A"), event(&store.session_id, "A"), "failure"); + store.seal(); + assert_eq!(store.counters.io_errors.load(Ordering::Relaxed), 1); + assert_eq!(store.counters.dropped.load(Ordering::Relaxed), 1); + assert!(store.events.is_empty()); +} + +#[test] +fn oversized_input_is_rejected_before_channel_and_byte_budget_is_bounded() { + let (sender, receiver) = mpsc::sync_channel(QUEUE_CAPACITY); + let writer = AnalyticsWriter { + sender, + counters: Arc::new(Counters::default()), + }; + let session = uuid::Uuid::new_v4().to_string(); + let mut huge = event(&session, "A"); + huge.client_version = "x".repeat(MAX_BYTES + 1); + assert!(!writer.try_record(route("A"), huge, "huge".into())); + assert!(matches!( + receiver.try_recv(), + Err(mpsc::TryRecvError::Empty) + )); + assert!(!writer.try_record(route("A"), event(&session, "A"), "x".repeat(4097))); + writer + .counters + .queued_bytes + .store(MAX_BYTES as u64, Ordering::Relaxed); + assert!(!writer.try_record(route("A"), event(&session, "A"), "full-bytes".into())); + assert!(matches!( + receiver.try_recv(), + Err(mpsc::TryRecvError::Empty) + )); +} + +#[test] +fn real_background_writer_flushes_when_last_sender_closes() { + let dir = tempfile::tempdir().unwrap(); + let session = uuid::Uuid::new_v4().to_string(); + let writer = AnalyticsWriter::start(dir.path().to_path_buf(), session.clone()); + assert!(writer.try_record(route("A"), event(&session, "A"), "background".into())); + drop(writer); + let until = Instant::now() + Duration::from_secs(5); + loop { + if let Ok(batches) = list_batches(&dir.path().join("analytics")) { + if let Some(batch) = batches.first() { + assert_eq!(read_batch(&batch.path).unwrap().event_count, 1); + break; + } + } + assert!(Instant::now() < until, "background batch was not published"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn retention_rebuilds_index_after_another_instance_removed_a_batch() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + store.record( + route("A"), + event(&store.session_id, "A"), + "removed-by-another-instance", + ); + store.seal(); + let batch = list_batches(&store.root).unwrap().pop().unwrap(); + remove_batch(&batch.path).unwrap(); + assert_eq!(store.known.len(), 1); + store.prune(0).unwrap(); + assert!(store.known.is_empty()); + assert!(store.known_ids.is_empty()); +} + +#[test] +fn unexpected_deep_tree_does_not_disable_unrelated_batches() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + let mut deep = store.root.join("unrecognized"); + for _ in 0..10 { + deep = deep.join("nested"); + } + fs::create_dir_all(&deep).unwrap(); + fs::write(deep.join("foreign"), b"leave intact").unwrap(); + store.record(route("A"), event(&store.session_id, "A"), "valid"); + store.seal(); + assert_eq!(list_batches(&store.root).unwrap().len(), 1); + assert!(deep.join("foreign").exists()); + assert!(store.counters.corrupt_batches.load(Ordering::Relaxed) > 0); +} + +fn project_event(session: &str, user: &str, name: &str) -> Event { + let mut event = event(session, user); + event.event_name = name.into(); + event.project_id = Some("project-1".into()); + event.creative_task_id = None; + event.properties = if name == "project_open" { + json!({"open_source":"recent","is_first_open":null}) + } else { + json!({"creation_source":"home_game"}) + }; + event +} + +#[test] +fn first_open_is_derived_from_route_scoped_known_facts_and_survives_restart() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + store.record( + route("A"), + project_event(&store.session_id, "A", "project_create_success"), + "create", + ); + store.record( + route("A"), + project_event(&store.session_id, "A", "project_open"), + "open1", + ); + assert_eq!( + store.events.last().unwrap().0.properties["is_first_open"], + true + ); + store.record( + route("A"), + project_event(&store.session_id, "A", "project_open"), + "open1", + ); + assert_eq!(store.events.len(), 2); + store.seal(); + let mut restored = open(dir.path()); + restored.record( + route("A"), + project_event(&restored.session_id, "A", "project_open"), + "open2", + ); + assert_eq!( + restored.events.last().unwrap().0.properties["is_first_open"], + false + ); + restored.record( + route("B"), + project_event(&restored.session_id, "B", "project_open"), + "open3", + ); + assert!(restored.events.last().unwrap().0.properties["is_first_open"].is_null()); +} + +fn session_record(session: &str) -> SessionRecord { + SessionRecord { + schema_version: 1, + editor_session_id: session.into(), + route: route("A"), + lifecycle_state: crate::analytics::session::LifecycleState::Active, + focus_interval_id: None, + updated_at: "2026-09-21T00:00:00.000Z".into(), + incomplete_detected_at: None, + } +} + +#[test] +fn session_commands_share_the_event_byte_budget_and_restore_on_send_failure() { + let (sender, receiver) = mpsc::sync_channel(4); + let counters = Arc::new(Counters::default()); + let writer = AnalyticsWriter { + sender, + counters: counters.clone(), + }; + let record = session_record(&uuid::Uuid::new_v4().to_string()); + let bytes = serde_json::to_vec(&record).unwrap().len() as u64; + counters + .queued_bytes + .store(MAX_BYTES as u64 - bytes + 1, Ordering::Relaxed); + assert!(!writer.try_session(record.clone())); + assert_eq!( + counters.queued_bytes.load(Ordering::Relaxed), + MAX_BYTES as u64 - bytes + 1 + ); + counters + .queued_bytes + .store(MAX_BYTES as u64 - bytes, Ordering::Relaxed); + assert!(writer.try_session(record.clone())); + assert_eq!( + counters.queued_bytes.load(Ordering::Relaxed), + MAX_BYTES as u64 + ); + match receiver.recv().unwrap() { + Command::Session(_, charged) => { + assert_eq!(charged, bytes); + counters.queued_bytes.fetch_sub(charged, Ordering::Relaxed); + } + _ => panic!("expected session"), + } + drop(receiver); + let before = counters.queued_bytes.load(Ordering::Relaxed); + assert!(!writer.try_session(record)); + assert_eq!(counters.queued_bytes.load(Ordering::Relaxed), before); +} + +#[test] +fn session_atomic_write_reserves_full_temporary_file_capacity() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + let record = session_record(&store.session_id); + let bytes = serde_json::to_vec(&record).unwrap().len() as u64; + store.write_session(&record, bytes).unwrap(); + let path = store.batches.parent().unwrap().join("session.json"); + let original = fs::read(&path).unwrap(); + let padding = File::create(store.root.join("unknown-padding")).unwrap(); + padding.set_len(MAX_TOTAL_BYTES - bytes).unwrap(); + assert!(store.write_session(&record, bytes).is_err()); + assert_eq!(fs::read(&path).unwrap(), original); + assert_eq!( + directory_size(&store.root, 0, &store.counters), + MAX_TOTAL_BYTES + ); +} + +fn goal_project() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + crate::init_local_game_project_at(&root, "goal-project", "目标采集测试").unwrap(); + (dir, root) +} + +fn goal_writer(config: &Path, user: &str) -> (super::super::contract::Context, AnalyticsWriter) { + let context = super::super::contract::Context { + route: route(user), + editor_session_id: uuid::Uuid::new_v4().to_string(), + client_version: "1.0.0".into(), + }; + let writer = AnalyticsWriter::start(config.into(), context.editor_session_id.clone()); + (context, writer) +} + +// 同 FIFO 的真实事件哨兵:哨兵落盘后,前面的命令必已执行,不靠 sleep 猜零事件。 +fn drain_goal_writer( + config: &Path, + context: &super::super::contract::Context, + writer: &AnalyticsWriter, +) -> Vec { + let marker = event( + &context.editor_session_id, + context.route.user_id.as_deref().unwrap(), + ); + let marker_id = marker.event_id.clone(); + assert!(writer.try_record(context.route.clone(), marker, marker_id.clone())); + assert!(writer.flush()); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let events: Vec<_> = list_batches(&config.join("analytics")) + .unwrap_or_default() + .iter() + .flat_map(|batch| read_events(&batch.path).unwrap_or_default()) + .collect(); + if events.iter().any(|event| event.event_id == marker_id) { + return events; + } + assert!( + Instant::now() < deadline, + "analytics FIFO sentinel timed out" + ); + std::thread::sleep(Duration::from_millis(5)); + } +} + +#[test] +fn project_goal_is_once_across_sources_writers_and_batch_retention_with_frozen_identity() { + use super::super::{contract::Source, goal}; + let (_project_dir, root) = goal_project(); + let config = tempfile::tempdir().unwrap(); + let (a, writer) = goal_writer(config.path(), "A"); + goal::created(&writer, &root, "goal-project"); + goal::accepted( + Some((a.clone(), writer.clone())), + &root, + "goal-project", + Source::DesignAgent, + ); + let mut b = a.clone(); + b.route = route("B"); + goal::accepted( + Some((b, writer.clone())), + &root, + "goal-project", + Source::Direct, + ); + let events = drain_goal_writer(config.path(), &a, &writer); + let submissions: Vec<_> = events + .iter() + .filter(|e| e.event_name == "creative_task_submit") + .collect(); + assert_eq!(submissions.len(), 1); + assert_eq!(submissions[0].user_id.as_deref(), Some("A")); + assert_eq!(submissions[0].source, Source::DesignAgent); + assert_eq!(submissions[0].properties, json!({})); + for batch in list_batches(&config.path().join("analytics")).unwrap() { + remove_batch(&batch.path).unwrap(); + } + let (restarted, next) = goal_writer(config.path(), "B"); + goal::created(&next, &root, "goal-project"); // 不覆盖 consumed + goal::accepted( + Some((restarted.clone(), next.clone())), + &root, + "goal-project", + Source::Direct, + ); + let events = drain_goal_writer(config.path(), &restarted, &next); + assert!(!events + .iter() + .any(|e| e.event_name == "creative_task_submit")); +} + +#[test] +fn goal_unknown_corrupt_backup_and_mismatched_projects_do_not_submit() { + use super::super::{contract::Source, goal}; + let (_project_dir, root) = goal_project(); + let config = tempfile::tempdir().unwrap(); + let (context, writer) = goal_writer(config.path(), "A"); + let accept = |project| { + goal::accepted( + Some((context.clone(), writer.clone())), + &root, + project, + Source::Direct, + ) + }; + accept("goal-project"); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.event_name == "creative_task_submit")); + let marker = root.join(".agent/analytics-goal.json"); + fs::write(&marker, b"broken").unwrap(); + goal::created(&writer, &root, "goal-project"); + accept("goal-project"); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.event_name == "creative_task_submit")); + assert_eq!(fs::read(&marker).unwrap(), b"broken"); + fs::remove_file(&marker).unwrap(); + let backup = crate::agent::agent_runtime_json_sidecar_backup_path(&marker); + fs::write( + &backup, + br#"{"schema_version":1,"project_id":"goal-project","submitted":false}"#, + ) + .unwrap(); + goal::created(&writer, &root, "goal-project"); + accept("goal-project"); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.event_name == "creative_task_submit")); + assert!(!marker.exists()); + fs::remove_file(backup).unwrap(); + goal::created(&writer, &root, "goal-project"); + accept("other-project"); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.event_name == "creative_task_submit")); +} + +#[test] +fn goal_queue_capacity_rejection_is_silent_and_does_not_charge_bytes() { + use super::super::goal; + let (sender, receiver) = mpsc::sync_channel(1); + let counters = Arc::new(Counters::default()); + let writer = AnalyticsWriter { + sender, + counters: counters.clone(), + }; + let (_dir, root) = goal_project(); + goal::created(&writer, &root, "goal-project"); + let charged = counters.queued_bytes.load(Ordering::Relaxed); + assert!(charged > 0); + goal::created(&writer, &root, "goal-project"); + assert_eq!(counters.queued_bytes.load(Ordering::Relaxed), charged); + assert_eq!(counters.dropped.load(Ordering::Relaxed), 1); + assert!(!root.join(".agent/analytics-goal.json").exists()); + drop(receiver); + counters + .queued_bytes + .store(MAX_BYTES as u64, Ordering::Relaxed); + goal::created(&writer, &root, "goal-project"); + assert_eq!( + counters.queued_bytes.load(Ordering::Relaxed), + MAX_BYTES as u64 + ); +} + +#[test] +fn later_real_acceptance_can_consume_after_lock_contention_and_two_writers_do_not_duplicate() { + use super::super::{contract::Source, goal}; + let (_project_dir, root) = goal_project(); + let config = tempfile::tempdir().unwrap(); + let (a, first) = goal_writer(config.path(), "A"); + let (b, second) = goal_writer(config.path(), "B"); + goal::created(&first, &root, "goal-project"); + drain_goal_writer(config.path(), &a, &first); + let lock = + crate::agent::try_acquire_game_creator_agent_runtime_task_lock(&root, "analytics-goal") + .unwrap() + .unwrap(); + goal::accepted( + Some((a.clone(), first.clone())), + &root, + "goal-project", + Source::DesignAgent, + ); + assert!(!drain_goal_writer(config.path(), &a, &first) + .iter() + .any(|event| event.event_name == "creative_task_submit")); + drop(lock); + // 两个 worker 的独立线程竞争同一资格,只可能其中一个消费成功。 + goal::accepted( + Some((a.clone(), first.clone())), + &root, + "goal-project", + Source::DesignAgent, + ); + goal::accepted( + Some((b.clone(), second.clone())), + &root, + "goal-project", + Source::Direct, + ); + drain_goal_writer(config.path(), &a, &first); + let events = drain_goal_writer(config.path(), &b, &second); + assert_eq!( + events + .iter() + .filter(|event| event.event_name == "creative_task_submit") + .count(), + 1 + ); +} + +fn run_outcome() -> super::super::run::Outcome { + super::super::run::Outcome { + turn_id: None, + end_reason: super::super::contract::RunEndReason::Finished, + error_code: None, + duration_ms: Some(123), + output_change_detected: None, + revision_id: None, + } +} + +#[test] +fn run_retry_counter_is_project_scoped_and_duplicate_acceptance_does_not_reopen_terminal() { + use super::super::{ + contract::{RunSource, Source}, + run, + }; + let (_project_dir, root) = goal_project(); + let config = tempfile::tempdir().unwrap(); + let (context, writer) = goal_writer(config.path(), "A"); + let initial = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); + let retry = run::Metadata::new(context.clone(), Source::DesignAgent, RunSource::UserRetry); + let later = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserContinue); + for metadata in [&initial, &retry, &later] { + run::accepted(&writer, &root, "goal-project", metadata); + run::accepted(&writer, &root, "goal-project", metadata); + run::finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + metadata, + run_outcome(), + ); + run::accepted(&writer, &root, "goal-project", metadata); + run::finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + metadata, + run_outcome(), + ); + } + let events = drain_goal_writer(config.path(), &context, &writer); + for (metadata, ordinal) in [(&initial, 0), (&retry, 1), (&later, 1)] { + let matching: Vec<_> = events + .iter() + .filter(|e| e.agent_run_id.as_deref() == Some(&metadata.run_id)) + .collect(); + assert_eq!(matching.len(), 1); + assert_eq!(matching[0].event_id, metadata.terminal_event_id); + assert_eq!(matching[0].properties["retry_index"], ordinal); + } + let state: serde_json::Value = + serde_json::from_slice(&fs::read(root.join(".agent/analytics-runs.json")).unwrap()) + .unwrap(); + assert_eq!(state["retry_count"], 1); + assert_eq!(state["direct"]["terminal_consumed"], true); + assert!(state.get("context").is_none()); +} + +#[test] +fn recovered_run_uses_original_account_and_current_gui_session_without_recounting() { + use super::super::{ + contract::{RunSource, Source}, + run, + }; + let (_project_dir, root) = goal_project(); + let config = tempfile::tempdir().unwrap(); + let (original, writer) = goal_writer(config.path(), "A"); + let metadata = run::Metadata::new(original.clone(), Source::DesignAgent, RunSource::UserRetry); + run::accepted(&writer, &root, "goal-project", &metadata); + drain_goal_writer(config.path(), &original, &writer); + let persisted: run::Metadata = + serde_json::from_slice(&serde_json::to_vec(&metadata).unwrap()).unwrap(); + let (mut current, recovered) = goal_writer(config.path(), "B"); + current.client_version = "2.0.0".into(); + let mut outcome = run_outcome(); + outcome.duration_ms = None; + run::finished( + Some((current.clone(), recovered.clone())), + &root, + "goal-project", + &persisted, + outcome.clone(), + ); + run::finished( + Some((current.clone(), recovered.clone())), + &root, + "goal-project", + &persisted, + outcome, + ); + let events = drain_goal_writer(config.path(), ¤t, &recovered); + let matching: Vec<_> = events + .iter() + .filter(|event| event.event_id == metadata.terminal_event_id) + .collect(); + assert_eq!(matching.len(), 1); + assert_eq!(matching[0].user_id.as_deref(), Some("A")); + assert_eq!(matching[0].editor_session_id, current.editor_session_id); + assert_eq!(matching[0].client_version, "2.0.0"); + assert_eq!(matching[0].properties["retry_index"], 1); + assert!(matching[0].properties["duration_ms"].is_null()); +} + +#[test] +fn missing_overwritten_or_unwritable_run_slots_do_not_fabricate_terminal_events() { + use super::super::{ + contract::{RunSource, Source}, + run, + }; + let (_project_dir, root) = goal_project(); + let config = tempfile::tempdir().unwrap(); + let (context, writer) = goal_writer(config.path(), "A"); + let first = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); + let next = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserContinue); + run::finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + &first, + run_outcome(), + ); + run::accepted(&writer, &root, "goal-project", &first); + run::accepted(&writer, &root, "goal-project", &next); + run::finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + &first, + run_outcome(), + ); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.agent_run_id.is_some())); + let lock = + crate::agent::try_acquire_game_creator_agent_runtime_task_lock(&root, "analytics-runs") + .unwrap() + .unwrap(); + run::finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + &next, + run_outcome(), + ); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.agent_run_id.is_some())); + drop(lock); + // 阻止sidecar完成替换后的备份清理:写入返回失败时不得投递成功事件。 + let path = root.join(".agent/analytics-runs.json"); + let backup = crate::agent::agent_runtime_json_sidecar_backup_path(&path); + fs::create_dir(&backup).unwrap(); + run::finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + &next, + run_outcome(), + ); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.agent_run_id.is_some())); + fs::remove_dir(backup).unwrap(); +} + +#[test] +fn direct_terminal_waits_for_exact_attempt_settlement_and_discards_intermediate_failures() { + use super::super::{ + contract::{ErrorCode, RunEndReason, RunSource, Source}, + run, + }; + let (_project_dir, root) = goal_project(); + let config = tempfile::tempdir().unwrap(); + let (context, writer) = goal_writer(config.path(), "A"); + let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); + run::accepted(&writer, &root, "goal-project", &metadata); + let first = uuid::Uuid::new_v4().to_string(); + let final_attempt = uuid::Uuid::new_v4().to_string(); + let capture = || Some((context.clone(), writer.clone())); + let mut failure = run_outcome(); + failure.end_reason = RunEndReason::Failed; + failure.error_code = Some(ErrorCode::ProviderTimeout); + run::direct_finished( + capture(), + &root, + "goal-project", + &metadata, + Some(&first), + failure.clone(), + ); + run::settle(capture(), &uuid::Uuid::new_v4().to_string(), false); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.agent_run_id.is_some())); + // 另一个被取消的尝试不消费槽位;保留第一次失败,验证最终确认不会选错它。 + let cancelled = uuid::Uuid::new_v4().to_string(); + run::direct_finished( + capture(), + &root, + "goal-project", + &metadata, + Some(&cancelled), + failure, + ); + run::settle(capture(), &cancelled, true); + run::settle(capture(), &cancelled, false); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.agent_run_id.is_some())); + run::direct_finished( + capture(), + &root, + "goal-project", + &metadata, + None, + run_outcome(), + ); + run::direct_finished( + capture(), + &root, + "goal-project", + &metadata, + Some(&final_attempt), + run_outcome(), + ); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.agent_run_id.is_some())); + run::settle(capture(), &final_attempt, false); + run::settle(capture(), &final_attempt, false); + let events = drain_goal_writer(config.path(), &context, &writer); + let terminal: Vec<_> = events.iter().filter(|e| e.agent_run_id.is_some()).collect(); + assert_eq!(terminal.len(), 1); + assert_eq!(terminal[0].event_name, "agent_run_completed"); + assert_eq!(terminal[0].event_id, metadata.terminal_event_id); +} + +#[test] +fn direct_pending_capacity_evicts_oldest_without_settlement_fallback() { + use super::super::{ + contract::{RunSource, Source}, + run, + }; + let (_project_dir, root) = goal_project(); + let config = tempfile::tempdir().unwrap(); + let (context, writer) = goal_writer(config.path(), "A"); + let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); + run::accepted(&writer, &root, "goal-project", &metadata); + let ids: Vec<_> = (0..17).map(|_| uuid::Uuid::new_v4().to_string()).collect(); + for id in &ids { + run::direct_finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + &metadata, + Some(id), + run_outcome(), + ); + } + run::settle(Some((context.clone(), writer.clone())), &ids[0], false); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.agent_run_id.is_some())); + assert!(writer.counters().dropped >= 1); + run::settle(Some((context.clone(), writer.clone())), &ids[16], false); + assert_eq!( + drain_goal_writer(config.path(), &context, &writer) + .iter() + .filter(|e| e.agent_run_id.is_some()) + .count(), + 1 + ); +} + +#[test] +fn direct_pending_byte_budget_and_session_are_checked_before_consumption() { + use super::super::{ + contract::{Context, RunSource, Source}, + run, + }; + let config = tempfile::tempdir().unwrap(); + let mut store = open(config.path()); + let context = Context { + route: route("A"), + editor_session_id: store.session_id.clone(), + client_version: "1.0.0".into(), + }; + let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); + let mut last = String::new(); + for _ in 0..10 { + last = uuid::Uuid::new_v4().to_string(); + // worker接收到的序列化预算是独立的保守保留量。 + store.run_request( + run::Request::DirectCandidate { + attempt_id: last.clone(), + terminal: Box::new(run::Request::Terminal { + root: config.path().into(), + project_id: "project".into(), + metadata: metadata.clone(), + context: context.clone(), + event_time: "2026-09-21T00:00:00.000Z".into(), + outcome: run_outcome(), + }), + }, + 128 * 1024, + ); + } + assert_eq!(store.pending_runs.len(), 8); + assert_eq!(store.pending_run_bytes, MAX_BYTES as u64); + store.run_request( + run::Request::Settle { + editor_session_id: uuid::Uuid::new_v4().to_string(), + attempt_id: last.clone(), + discard: true, + }, + 128, + ); + assert_eq!(store.pending_runs.len(), 8); + store.run_request( + run::Request::Settle { + editor_session_id: context.editor_session_id, + attempt_id: last, + discard: true, + }, + 128, + ); + assert_eq!(store.pending_runs.len(), 7); + assert_eq!(store.pending_run_bytes, 7 * 128 * 1024); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index a079cca8b..b0d074010 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -590,11 +590,26 @@ pub(crate) fn create_automatic_local_game_project( planning: Option, projects_root: Option, ) -> Result { - create_automatic_local_game_project_at( + let analytics_context = crate::analytics::gui::capture_analytics_context(); + let result = create_automatic_local_game_project_at( &resolve_game_project_creation_root(&app, projects_root.as_deref())?, name.as_deref(), planning.unwrap_or(false), - ) + ); + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + if planning.unwrap_or(false) { + crate::analytics::contract::CreationSource::HomeDesign + } else { + crate::analytics::contract::CreationSource::HomeGame + }, + None, + ); + } + result } #[tauri::command] @@ -603,10 +618,24 @@ pub(crate) fn init_local_game_project( project_id: String, name: String, ) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.create")?; let _lock = acquire_project_write_lock(root, "project.create")?; - init_local_game_project_at(root, project_id.trim(), name.trim()) + let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); + let result = init_local_game_project_at(root, project_id.trim(), name.trim()); + if new_project { + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::SelectedDirectory, + None, + ); + } + } + result } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -649,13 +678,27 @@ pub(crate) fn import_local_godot_project( project_id: String, name: String, ) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.create")?; if discover_local_godot_project_root(root)?.is_none() { return Err("所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string()); } let _lock = acquire_project_write_lock(root, "project.create")?; - import_local_godot_project_at(root, project_id.trim(), name.trim()) + let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); + let result = import_local_godot_project_at(root, project_id.trim(), name.trim()); + if new_project { + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::SelectedDirectory, + None, + ); + } + } + result } #[tauri::command] @@ -664,6 +707,7 @@ pub(crate) fn import_local_cocos_project( project_id: String, name: String, ) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.create")?; if discover_local_cocos_project_root(root)?.is_none() { @@ -673,7 +717,20 @@ pub(crate) fn import_local_cocos_project( ); } let _lock = acquire_project_write_lock(root, "project.create")?; - import_local_cocos_project_at(root, project_id.trim(), name.trim()) + let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); + let result = import_local_cocos_project_at(root, project_id.trim(), name.trim()); + if new_project { + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::SelectedDirectory, + None, + ); + } + } + result } #[tauri::command] @@ -682,13 +739,27 @@ pub(crate) fn import_local_unity_project( project_id: String, name: String, ) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.create")?; if discover_local_unity_project_root(root)?.is_none() { return Err("所选目录不是有效的 Unity 项目".to_string()); } let _lock = acquire_project_write_lock(root, "project.create")?; - import_local_unity_project_at(root, project_id.trim(), name.trim()) + let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); + let result = import_local_unity_project_at(root, project_id.trim(), name.trim()); + if new_project { + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::SelectedDirectory, + None, + ); + } + } + result } #[tauri::command] @@ -5651,11 +5722,79 @@ pub(crate) fn write_local_project_file( relative_path: String, content: String, ) -> Result { + write_local_project_file_with_capture( + project_path, + relative_path, + content, + crate::analytics::gui::capture_writer_context(), + ) +} + +type ManualAnalyticsCapture = Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, +)>; + +#[cfg(test)] +#[path = "commands_manual_analytics_tests.rs"] +mod manual_analytics_tests; + +fn write_local_project_file_with_capture( + project_path: String, + relative_path: String, + content: String, + capture: ManualAnalyticsCapture, +) -> Result { + use crate::analytics::{ + contract::{ProjectSaved, RevisionCreated, RevisionSource, SaveSource, Source}, + project, + }; let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "file.write")?; let _lock = acquire_project_write_lock(root, "file.write")?; - advance_agent_runtime_project_revision_locked(root)?; - write_local_project_file_at(root, relative_path.trim(), &content) + let project_id = capture + .as_ref() + .and_then(|_| read_existing_manifest_for_project(root).ok()) + .map(|manifest| manifest.project_id); + let kind = normalize_relative_path(relative_path.trim()) + .ok() + .and_then(|path| project::file_change_kind(&path)); + let changed = project_id + .as_ref() + .and_then(|_| kind.as_ref()) + .and_then(|_| { + project::file_content_changed(root, relative_path.trim(), content.as_bytes(), 1_500_000) + }); + let operation_id = uuid::Uuid::new_v4().to_string(); + let revision = advance_agent_runtime_project_revision_locked(root)?; + let result = write_local_project_file_at(root, relative_path.trim(), &content)?; + if let (Some(project_id), Some(kind)) = (project_id, kind) { + if changed == Some(true) { + project::revision( + capture.clone(), + &project_id, + Source::Manual, + RevisionCreated { + revision_id: revision.to_string(), + revision_source: RevisionSource::ManualEdit, + change_kind: kind, + files_changed_count: Some(1), + }, + ); + } + project::saved( + capture, + &project_id, + Source::Manual, + &operation_id, + ProjectSaved { + save_source: SaveSource::Manual, + revision_id: Some(revision.to_string()), + }, + None, + ); + } + Ok(result) } #[tauri::command] @@ -5663,11 +5802,49 @@ pub(crate) fn delete_local_project_file( project_path: String, relative_path: String, ) -> Result { + delete_local_project_file_with_capture( + project_path, + relative_path, + crate::analytics::gui::capture_writer_context(), + ) +} + +fn delete_local_project_file_with_capture( + project_path: String, + relative_path: String, + capture: ManualAnalyticsCapture, +) -> Result { + use crate::analytics::{ + contract::{RevisionCreated, RevisionSource, Source}, + project, + }; let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "file.delete")?; let _lock = acquire_project_write_lock(root, "file.delete")?; - advance_agent_runtime_project_revision_locked(root)?; - delete_local_project_file_at(root, relative_path.trim()) + let project_id = capture + .as_ref() + .and_then(|_| read_existing_manifest_for_project(root).ok()) + .map(|manifest| manifest.project_id); + let revision = advance_agent_runtime_project_revision_locked(root)?; + let result = delete_local_project_file_at(root, relative_path.trim())?; + if result.deleted { + if let (Some(project_id), Some(kind)) = + (project_id, project::file_change_kind(&result.path)) + { + project::revision( + capture, + &project_id, + Source::Manual, + RevisionCreated { + revision_id: revision.to_string(), + revision_source: RevisionSource::ManualEdit, + change_kind: kind, + files_changed_count: Some(1), + }, + ); + } + } + Ok(result) } #[tauri::command] @@ -5993,10 +6170,50 @@ pub(crate) fn build_local_project_index( pub(crate) fn create_local_project_checkpoint( project_path: String, ) -> Result { + create_local_project_checkpoint_with_capture( + project_path, + crate::analytics::gui::capture_writer_context(), + ) +} + +fn create_local_project_checkpoint_with_capture( + project_path: String, + capture: ManualAnalyticsCapture, +) -> Result { + use crate::analytics::{ + contract::{ProjectSaved, SaveSource, Source}, + project, + }; let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.checkpoint")?; let _lock = acquire_project_write_lock(root, "project.checkpoint")?; - create_local_project_checkpoint_at(root) + let project_id = capture + .as_ref() + .and_then(|_| read_existing_manifest_for_project(root).ok()) + .map(|manifest| manifest.project_id); + let result = create_local_project_checkpoint_at(root)?; + if let Some(project_id) = project_id { + project::saved( + capture, + &project_id, + Source::Manual, + &result.checkpoint_id, + ProjectSaved { + save_source: SaveSource::Checkpoint, + revision_id: None, + }, + None, + ); + } + Ok(result) +} + +#[cfg(test)] +pub(crate) fn checkpoint_with_capture_for_test( + project_path: String, + capture: ManualAnalyticsCapture, +) -> Result { + create_local_project_checkpoint_with_capture(project_path, capture) } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands_manual_analytics_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/commands_manual_analytics_tests.rs new file mode 100644 index 000000000..e08f76841 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/commands_manual_analytics_tests.rs @@ -0,0 +1,264 @@ +use super::*; + +#[test] +fn ui_gui_save_records_only_saved_revision_with_frozen_identity() { + use crate::ui_editor::persistence::{ + initialize_ui_design_state_at, SaveUiDesignStateInput, SaveUiDesignStateResult, + }; + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + let project_id = "ui-analytics"; + init_local_game_project_at(&root, project_id, "UI 成果采集").unwrap(); + let relative_path = "ui/design.json"; + fs::create_dir_all(root.join("ui")).unwrap(); + fs::write(root.join(relative_path), b"").unwrap(); + let asset = register_local_asset_at( + &root, + relative_path, + GameCreationAppAssetKind::UiDesignDoc, + shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE, + "test", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: Some("ui:test".into()), + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .unwrap(); + initialize_ui_design_state_at(&root, project_id, &asset.id).unwrap(); + let config = temp.path().join("config"); + fs::create_dir_all(&config).unwrap(); + let mut current = Context { + route: Route::from_identity(Some("A".into()), Some("https://example.com")), + editor_session_id: uuid::Uuid::new_v4().to_string(), + client_version: "1.0.0".into(), + }; + let writer = AnalyticsWriter::start(config.clone(), current.editor_session_id.clone()); + let capture = Some((current.clone(), writer.clone())); + current.route.user_id = Some("B".into()); + let state = serde_json::from_value(serde_json::json!({ + "ui_trees": [], "ui_design_images": {"page": { + "metadata": {"name":"主界面", "description":"", "role":"Page", "slave_to":null}, + "path":"assets/missing-reference.png", "pixel_size":[1280.0,720.0], "pixels_per_unit":1.0 + }}, "sprite_assets": {}, "font_assets": {} + })).unwrap(); + let mut input = SaveUiDesignStateInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: project_id.into(), + asset_id: asset.id, + expected_revision: 0, + state, + }; + let saved = crate::save_ui_design_state_with_capture(input.clone(), capture.clone()).unwrap(); + let SaveUiDesignStateResult::Saved { + revision, + committed_project_revision, + .. + } = saved + else { + panic!("expected real Saved") + }; + assert!(matches!( + crate::save_ui_design_state_with_capture(input.clone(), capture.clone()).unwrap(), + SaveUiDesignStateResult::Conflict { .. } + )); + input.expected_revision = revision; + assert!(matches!( + crate::save_ui_design_state_with_capture(input.clone(), capture.clone()).unwrap(), + SaveUiDesignStateResult::Unchanged { .. } + )); + input.expected_project_id = "wrong-project".into(); + assert!(crate::save_ui_design_state_with_capture(input, capture).is_err()); + let events = drain(&config, ¤t, &writer); + let revisions: Vec<_> = events + .iter() + .filter(|event| event["event_name"] == "project_revision_created") + .collect(); + assert_eq!(revisions.len(), 1); + let event = revisions[0]; + assert_eq!(event["user_id"], "A"); + assert_eq!(event["project_id"], project_id); + assert_eq!(event["source"], "ui_editor"); + assert_eq!(event["properties"]["revision_source"], "ui_editor"); + assert_eq!(event["properties"]["change_kind"], "ui"); + assert_eq!( + event["properties"]["revision_id"], + committed_project_revision.to_string() + ); + assert!(!events + .iter() + .any(|event| event["event_name"] == "project_save")); +} +use crate::analytics::{ + contract::{Context, EntrySource, EventData, Route, SessionStart, Source}, + store::AnalyticsWriter, +}; + +fn drain(config: &Path, context: &Context, writer: &AnalyticsWriter) -> Vec { + let marker = context + .capture( + EventData::EditorSessionStart(SessionStart { + entry_source: EntrySource::DirectLaunch, + first_project_id: None, + }), + None, + Source::Editor, + None, + ) + .unwrap(); + let marker_id = marker.event_id.clone(); + assert!(writer.try_record(context.route.clone(), marker, marker_id.clone())); + assert!(writer.flush()); + let batches = config + .join("analytics/instances") + .join(&context.editor_session_id) + .join("batches"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + let events: Vec = fs::read_dir(&batches) + .into_iter() + .flatten() + .flatten() + .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.')) + .filter_map(|entry| fs::read_to_string(entry.path().join("events.jsonl")).ok()) + .flat_map(|text| { + text.lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect::>() + }) + .collect(); + if events.iter().any(|event| event["event_id"] == marker_id) { + return events; + } + assert!( + std::time::Instant::now() < deadline, + "manual analytics FIFO sentinel timed out" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + +#[test] +fn manual_commands_record_committed_changes_explicit_saves_and_complete_checkpoint() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + init_local_game_project_at(&root, "manual-analytics", "人工操作采集").unwrap(); + let config = temp.path().join("config"); + fs::create_dir_all(&config).unwrap(); + let mut current = Context { + route: Route::from_identity(Some("A".into()), Some("https://example.com")), + editor_session_id: uuid::Uuid::new_v4().to_string(), + client_version: "1.0.0".into(), + }; + let writer = AnalyticsWriter::start(config.clone(), current.editor_session_id.clone()); + let captured = Some((current.clone(), writer.clone())); + current.route.user_id = Some("B".into()); + let path = root.to_string_lossy().into_owned(); + for _ in 0..2 { + write_local_project_file_with_capture( + path.clone(), + "game/result.js".into(), + "const result = 1;".into(), + captured.clone(), + ) + .unwrap(); + } + assert!( + delete_local_project_file_with_capture( + path.clone(), + "game/result.js".into(), + captured.clone() + ) + .unwrap() + .deleted + ); + assert!( + !delete_local_project_file_with_capture( + path.clone(), + "game/result.js".into(), + captured.clone() + ) + .unwrap() + .deleted + ); + assert!(write_local_project_file_with_capture( + path.clone(), + "../escape.js".into(), + "bad".into(), + captured.clone() + ) + .is_err()); + assert!(delete_local_project_file_with_capture( + path.clone(), + "../escape.js".into(), + captured.clone() + ) + .is_err()); + write_local_project_file_with_capture( + path.clone(), + "notes.txt".into(), + "unclassified".into(), + captured.clone(), + ) + .unwrap(); + let checkpoint = + create_local_project_checkpoint_with_capture(path.clone(), captured.clone()).unwrap(); + assert!(checkpoint.file_count > 0); + let blocked = temp.path().join("blocked-checkpoint"); + init_local_game_project_at(&blocked, "blocked-checkpoint", "checkpoint 失败夹具").unwrap(); + fs::write(blocked.join(".agent/checkpoints"), b"not a directory").unwrap(); + assert!(create_local_project_checkpoint_with_capture( + blocked.to_string_lossy().into_owned(), + captured, + ) + .is_err()); + let events = drain(&config, ¤t, &writer); + let revisions: Vec<_> = events + .iter() + .filter(|event| event["event_name"] == "project_revision_created") + .collect(); + assert_eq!( + revisions.len(), + 2, + "same-content, absent deletion and failures are not成果" + ); + assert_ne!( + revisions[0]["properties"]["revision_id"], + revisions[1]["properties"]["revision_id"] + ); + let saves: Vec<_> = events + .iter() + .filter(|event| event["event_name"] == "project_save") + .collect(); + assert_eq!( + saves.len(), + 3, + "two explicit writes plus one complete checkpoint" + ); + assert_eq!( + saves + .iter() + .filter(|event| event["properties"]["save_source"] == "manual") + .count(), + 2 + ); + let checkpoint_event = saves + .iter() + .find(|event| event["properties"]["save_source"] == "checkpoint") + .unwrap(); + assert!(checkpoint_event["properties"].get("revision_id").is_none()); + for event in revisions.iter().chain(saves.iter()) { + assert_eq!(event["user_id"], "A"); + assert_eq!(event["project_id"], "manual-analytics"); + assert_eq!(event["source"], "manual"); + assert!(event["agent_run_id"].is_null()); + assert!(event["agent_turn_id"].is_null()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index cb3f75eb7..218aafe2c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -61,6 +61,7 @@ use tauri_plugin_opener::OpenerExt; /// 更新完成后的进程重启:Windows 由 NSIS 安装程序代为重启,macOS / Linux 由客户端在安装后调用。 #[tauri::command] fn restart_agc_app(app: tauri::AppHandle) { + analytics::gui::mark_restart(); app.restart(); } @@ -110,6 +111,7 @@ include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs")); mod agent; mod agent_native_tools; +mod analytics; mod asset_generation_tasks; mod assets; mod browser; @@ -274,9 +276,38 @@ fn load_ui_design_state( fn save_ui_design_state( input: ui_editor::persistence::SaveUiDesignStateInput, ) -> Result { + save_ui_design_state_with_capture(input, analytics::gui::capture_writer_context()) +} + +fn save_ui_design_state_with_capture( + input: ui_editor::persistence::SaveUiDesignStateInput, + capture: Option<( + analytics::contract::Context, + analytics::store::AnalyticsWriter, + )>, +) -> Result { + let project_id = input.expected_project_id.clone(); let root = Path::new(input.project_path.trim()); enforce_project_permission_policy(root, "asset.register")?; - ui_editor::persistence::save_ui_design_state_at(input) + let result = ui_editor::persistence::save_ui_design_state_at(input)?; + if let ui_editor::persistence::SaveUiDesignStateResult::Saved { + committed_project_revision, + .. + } = &result + { + analytics::project::revision( + capture, + &project_id, + analytics::contract::Source::UiEditor, + analytics::contract::RevisionCreated { + revision_id: committed_project_revision.to_string(), + revision_source: analytics::contract::RevisionSource::UiEditor, + change_kind: analytics::contract::ChangeKind::Ui, + files_changed_count: None, + }, + ); + } + Ok(result) } #[tauri::command] @@ -2439,7 +2470,15 @@ fn main() { .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(context_menu::init()) - .on_window_event(|window, event| handle_project_snapshot_window_event(window, event)) + .on_page_load(|webview, payload| { + if matches!(payload.event(), tauri::webview::PageLoadEvent::Started) { + analytics::gui::page_loading(webview.window().label()); + } + }) + .on_window_event(|window, event| { + analytics::gui::window_event(window, event); + handle_project_snapshot_window_event(window, event); + }) .manage(game_creator_preview_registry()) .manage(ProjectResourcePreviewReadManager::default()) .manage(PluginHost::default()) @@ -2543,6 +2582,11 @@ fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + analytics::gui::capture_analytics_context, + analytics::gui::settle_direct_run_analytics, + analytics::gui::record_analytics_project_open, + analytics::gui::record_analytics_project_leave, + analytics::gui::record_analytics_ui_save, start_game_creator_external_mcp, stop_game_creator_external_mcp, create_automatic_local_game_project, @@ -2748,7 +2792,21 @@ fn main() { std::process::exit(1); } }; - app.run(move |_app_handle, event| handle_game_creator_gui_run_event(&event)); + app.run(move |app_handle, event| { + if matches!(event, tauri::RunEvent::Ready) { + if let Some(directory) = game_creator_runtime_config_dir() { + analytics::gui::initialize( + app_handle.clone(), + directory, + app_handle.package_info().version.to_string(), + ); + } + } + if matches!(event, tauri::RunEvent::Exit) { + analytics::gui::exit(); + } + handle_game_creator_gui_run_event(&event); + }); } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index 41fe89783..a58d9eebf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -289,6 +289,46 @@ struct PlatformSessionState { static PLATFORM_SESSION: OnceLock> = OnceLock::new(); +// 仅用于同进程埋点通知排序,不替代认证的 revision/身份代次,不持久化凭据。 +static ANALYTICS_IDENTITY_SEQUENCE: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + +fn analytics_route(current: &PlatformSessionState) -> crate::analytics::contract::Route { + crate::analytics::contract::Route::from_identity( + current.snapshot.as_ref().map(|s| s.user_id.clone()), + current.snapshot.as_ref().map(|s| s.api_base_url.as_str()), + ) +} + +fn analytics_identity_notice( + current: &PlatformSessionState, +) -> (crate::analytics::contract::Route, u64) { + let sequence = + ANALYTICS_IDENTITY_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + (analytics_route(current), sequence) +} + +pub(crate) fn analytics_identity_snapshot() -> Option<(crate::analytics::contract::Route, u64)> { + let current = platform_session().try_lock().ok()?; + Some(( + analytics_route(¤t), + ANALYTICS_IDENTITY_SEQUENCE.load(std::sync::atomic::Ordering::Relaxed), + )) +} + +// 仅供后台初始化使用:持锁发布 GUI 身份,避免快照与通知之间出现空窗。 +// 回调只允许更新内存或非阻塞投递,不能访问磁盘或重新取得认证锁。 +pub(crate) fn initialize_analytics_identity( + initialize: impl FnOnce(crate::analytics::contract::Route, u64), +) { + if let Ok(current) = platform_session().lock() { + initialize( + analytics_route(¤t), + ANALYTICS_IDENTITY_SEQUENCE.load(std::sync::atomic::Ordering::Relaxed), + ); + } +} + fn platform_session() -> &'static Mutex { PLATFORM_SESSION.get_or_init(|| Mutex::new(PlatformSessionState::default())) } @@ -380,6 +420,9 @@ pub(crate) fn install_platform_session( snapshot.identity_generation, snapshot.revision, ); + let (route, sequence) = analytics_identity_notice(¤t); + drop(current); + crate::analytics::gui::identity_changed(route, sequence); Ok(()) } @@ -453,6 +496,9 @@ pub(crate) fn replace_platform_session_for_gui_owner( current.revision = snapshot.revision; current.identity_generation = snapshot.identity_generation; current.snapshot = Some(snapshot); + let (route, sequence) = analytics_identity_notice(¤t); + drop(current); + crate::analytics::gui::identity_changed(route, sequence); Ok(()) } @@ -481,7 +527,11 @@ pub(crate) fn install_platform_session_checked( snapshot.identity_generation, snapshot.revision, ); - if current.snapshot.as_ref() == Some(&snapshot) { + let accepted = current.snapshot.as_ref() == Some(&snapshot); + let (route, sequence) = analytics_identity_notice(¤t); + drop(current); + crate::analytics::gui::identity_changed(route, sequence); + if accepted { Ok(()) } else { Err("authentication-required: 平台登录态写入已过期或主体冲突".to_string()) @@ -519,6 +569,9 @@ pub(crate) fn clear_platform_session(identity_generation: u64, revision: u64) { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); clear_platform_session_in(&mut current, identity_generation, revision); + let (route, sequence) = analytics_identity_notice(¤t); + drop(current); + crate::analytics::gui::identity_changed(route, sequence); } pub(crate) fn clear_platform_session_for_gui_owner(identity_generation: u64, revision: u64) { @@ -529,6 +582,9 @@ pub(crate) fn clear_platform_session_for_gui_owner(identity_generation: u64, rev current.revision = revision; current.identity_generation = identity_generation; current.snapshot = None; + let (route, sequence) = analytics_identity_notice(¤t); + drop(current); + crate::analytics::gui::identity_changed(route, sequence); } pub(crate) fn clear_platform_session_checked( @@ -539,10 +595,13 @@ pub(crate) fn clear_platform_session_checked( .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); clear_platform_session_in(&mut current, identity_generation, revision); - if current.revision >= revision + let accepted = current.revision >= revision && current.identity_generation >= identity_generation - && current.snapshot.is_none() - { + && current.snapshot.is_none(); + let (route, sequence) = analytics_identity_notice(¤t); + drop(current); + crate::analytics::gui::identity_changed(route, sequence); + if accepted { Ok(()) } else { Err("authentication-required: 平台登出写入已过期".to_string()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index e678d6438..6a6520abe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -8,6 +8,7 @@ pub(crate) struct PreviewRegistry { struct PreviewServer { preview: LocalPreviewResult, stop: mpsc::Sender<()>, + _analytics_lease: Option, } impl PreviewRegistry { @@ -15,6 +16,15 @@ impl PreviewRegistry { &self, preview: LocalPreviewResult, stop: mpsc::Sender<()>, + ) -> (LocalPreviewResult, Option) { + self.set_running_with_lease(preview, stop, None) + } + + fn set_running_with_lease( + &self, + preview: LocalPreviewResult, + stop: mpsc::Sender<()>, + analytics_lease: Option, ) -> (LocalPreviewResult, Option) { let mut current = self.current.lock().expect("preview registry lock"); let previous_preview = if let Some(previous) = current.take() { @@ -27,6 +37,7 @@ impl PreviewRegistry { *current = Some(PreviewServer { preview: preview.clone(), stop, + _analytics_lease: analytics_lease, }); (preview, previous_preview) } @@ -713,10 +724,14 @@ pub(crate) fn filter_preview_status_for_project( pub(crate) fn start_local_game_preview( project_path: String, expected_revision: Option, + preview_source: Option, registry: tauri::State<'_, PreviewRegistry>, ) -> Result { let root = Path::new(project_path.trim()); - start_local_game_preview_at_revision(root, expected_revision, ®istry) + let capture = (preview_source == Some(crate::analytics::contract::PreviewSource::User)) + .then(crate::analytics::gui::capture_writer_context) + .flatten(); + start_local_game_preview_with_capture(root, expected_revision, ®istry, capture) } pub(crate) fn start_local_game_preview_at( @@ -730,6 +745,18 @@ pub(crate) fn start_local_game_preview_at_revision( root: &Path, expected_revision: Option, registry: &PreviewRegistry, +) -> Result { + start_local_game_preview_with_capture(root, expected_revision, registry, None) +} + +fn start_local_game_preview_with_capture( + root: &Path, + expected_revision: Option, + registry: &PreviewRegistry, + capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, ) -> Result { enforce_project_permission_policy(root, "preview.start")?; let _lock = acquire_project_write_lock(root, "preview.start")?; @@ -741,6 +768,16 @@ pub(crate) fn start_local_game_preview_at_revision( )); } } + let observation = crate::analytics::preview::prepare( + root, + capture, + crate::analytics::contract::Source::Editor, + crate::analytics::contract::PreviewSource::User, + ); + let (analytics_lease, observation) = match observation { + Some((lease, observation)) => (Some(lease), Some(observation)), + None => (None, None), + }; let (preview, stop) = start_local_game_preview_for_project(root)?; if let Err(error) = record_preview_state( root, @@ -756,7 +793,8 @@ pub(crate) fn start_local_game_preview_at_revision( let _ = record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None); return Err(error); } - let (preview, previous_preview) = registry.set_running(preview, stop); + let (preview, previous_preview) = + registry.set_running_with_lease(preview, stop, analytics_lease); if let Some(previous_preview) = previous_preview.as_ref() { // 同一个项目重启预览(换监听线程、换端口)时,旧的 registry 身份与新预览共享 // 同一份落盘记录:上面刚写进去的是 running,这里若再用旧预览收尾,就会把它覆盖 @@ -772,9 +810,16 @@ pub(crate) fn start_local_game_preview_at_revision( let _ = record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None); return Err(error); } + if let Some(observation) = observation { + observation.schedule(preview.port); + } Ok(preview) } +#[cfg(test)] +#[path = "preview_analytics_tests.rs"] +mod analytics_tests; + #[tauri::command] pub(crate) fn stop_local_game_preview( project_path: Option, diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview_analytics_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/preview_analytics_tests.rs new file mode 100644 index 000000000..99caee428 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/preview_analytics_tests.rs @@ -0,0 +1,297 @@ +use super::*; +use crate::analytics::{ + contract::{Context, EntrySource, EventData, PreviewSource, Route, SessionStart, Source}, + preview::{prepare, Lease, Observation}, + store::AnalyticsWriter, +}; + +struct Fixture { + _temp: tempfile::TempDir, + root: PathBuf, + config: PathBuf, + context: Context, + writer: AnalyticsWriter, +} + +impl Fixture { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + init_local_game_project_at(&root, "preview-analytics", "预览埋点").unwrap(); + fs::create_dir_all(project_game_root(&root)).unwrap(); + fs::write( + project_game_root(&root).join("index.html"), + "游戏入口", + ) + .unwrap(); + let config = temp.path().join("config"); + fs::create_dir_all(&config).unwrap(); + let context = Context { + route: Route::from_identity(Some("A".into()), Some("https://example.com")), + editor_session_id: uuid::Uuid::new_v4().to_string(), + client_version: "1.0.0".into(), + }; + let writer = AnalyticsWriter::start(config.clone(), context.editor_session_id.clone()); + Self { + _temp: temp, + root, + config, + context, + writer, + } + } + + fn capture(&self) -> Option<(Context, AnalyticsWriter)> { + Some((self.context.clone(), self.writer.clone())) + } + + fn observation(&self, source: Source, preview_source: PreviewSource) -> (Lease, Observation) { + prepare(&self.root, self.capture(), source, preview_source).unwrap() + } + + fn events(&self) -> Vec { + let batches = self + .config + .join("analytics/instances") + .join(&self.context.editor_session_id) + .join("batches"); + fs::read_dir(batches) + .into_iter() + .flatten() + .flatten() + .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.')) + .filter_map(|entry| fs::read_to_string(entry.path().join("events.jsonl")).ok()) + .flat_map(|text| { + text.lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect::>() + }) + .collect() + } + + async fn drained(&self) -> Vec { + let marker = self + .context + .capture( + EventData::EditorSessionStart(SessionStart { + entry_source: EntrySource::DirectLaunch, + first_project_id: None, + }), + None, + Source::Editor, + None, + ) + .unwrap(); + let marker_id = marker.event_id.clone(); + assert!(self + .writer + .try_record(self.context.route.clone(), marker, marker_id.clone())); + assert!(self.writer.flush()); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + let events = self.events(); + if events.iter().any(|event| event["event_id"] == marker_id) { + return events; + } + assert!( + std::time::Instant::now() < deadline, + "preview writer FIFO timeout" + ); + tokio::time::sleep(Duration::from_millis(5)).await; + } + } +} + +fn ready(events: &[serde_json::Value]) -> Vec<&serde_json::Value> { + events + .iter() + .filter(|event| event["event_name"] == "preview_ready") + .collect() +} + +#[tokio::test] +async fn preview_analytics_real_entry_and_reopen_have_distinct_instances_and_original_identity() { + let fixture = Fixture::new(); + let version = read_game_creator_agent_runtime_project_revision(&fixture.root) + .unwrap() + .revision; + let mut current = fixture.context.clone(); + for source in [PreviewSource::User, PreviewSource::Agent] { + let event_source = if source == PreviewSource::User { + Source::Editor + } else { + Source::Direct + }; + let (_lease, observation) = fixture.observation(event_source, source); + current.route.user_id = Some("B".into()); + let (preview, stop) = start_local_game_preview_for_project(&fixture.root).unwrap(); + observation.observe(preview.port).await; + let _ = stop.send(()); + } + assert_eq!(current.route.user_id.as_deref(), Some("B")); + let events = fixture.drained().await; + let events = ready(&events); + assert_eq!(events.len(), 2); + assert_ne!(events[0]["event_id"], events[1]["event_id"]); + for event in &events { + assert_eq!(event["user_id"], "A"); + assert_eq!(event["project_id"], "preview-analytics"); + assert_eq!(event["creative_task_id"], "preview-analytics"); + assert_eq!(event["properties"]["preview_version"], version.to_string()); + assert!(event["agent_run_id"].is_null()); + assert!(event["agent_turn_id"].is_null()); + assert!(!event.to_string().contains("127.0.0.1")); + } + assert!(events.iter().any( + |event| event["source"] == "editor" && event["properties"]["preview_source"] == "user" + )); + assert!(events.iter().any( + |event| event["source"] == "direct" && event["properties"]["preview_source"] == "agent" + )); +} + +#[tokio::test] +async fn preview_analytics_missing_empty_stopped_or_changed_entry_is_not_ready() { + let fixture = Fixture::new(); + let entry = project_game_root(&fixture.root).join("index.html"); + let (preview, stop) = start_local_game_preview_for_project(&fixture.root).unwrap(); + let (lease, observation) = fixture.observation(Source::Direct, PreviewSource::Agent); + drop(lease); + observation.observe(preview.port).await; + let (_lease, observation) = fixture.observation(Source::Editor, PreviewSource::User); + fs::write(&entry, b"").unwrap(); + observation.observe(preview.port).await; + assert!(prepare( + &fixture.root, + fixture.capture(), + Source::Editor, + PreviewSource::User + ) + .is_none()); + fs::write(&entry, b"entry").unwrap(); + let (_lease, observation) = fixture.observation(Source::Editor, PreviewSource::User); + fs::remove_file(&entry).unwrap(); + observation.observe(preview.port).await; + fs::write(&entry, b"entry").unwrap(); + let (_lease, observation) = fixture.observation(Source::Editor, PreviewSource::User); + { + let _lock = acquire_project_write_lock(&fixture.root, "file.write").unwrap(); + advance_agent_runtime_project_revision_locked(&fixture.root).unwrap(); + } + observation.observe(preview.port).await; + let _ = stop.send(()); + assert!(ready(&fixture.drained().await).is_empty()); +} + +#[tokio::test] +async fn preview_analytics_checks_version_again_after_the_actual_request() { + let fixture = Fixture::new(); + let (_lease, observation) = fixture.observation(Source::Editor, PreviewSource::User); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let (requested, request_received) = tokio::sync::oneshot::channel(); + let (allow_response, response_allowed) = mpsc::channel(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + assert!(read_preview_request_line(&mut stream).unwrap().is_some()); + requested.send(()).unwrap(); + response_allowed + .recv_timeout(Duration::from_secs(3)) + .unwrap(); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nready") + .unwrap(); + }); + let probe = tokio::spawn(observation.observe(port)); + tokio::time::timeout(Duration::from_secs(3), request_received) + .await + .unwrap() + .unwrap(); + { + let _lock = acquire_project_write_lock(&fixture.root, "file.write").unwrap(); + advance_agent_runtime_project_revision_locked(&fixture.root).unwrap(); + } + allow_response.send(()).unwrap(); + probe.await.unwrap(); + server.join().unwrap(); + assert!(ready(&fixture.drained().await).is_empty()); +} + +#[tokio::test] +async fn preview_analytics_http_failure_or_empty_response_is_not_ready() { + let fixture = Fixture::new(); + for response in [ + "HTTP/1.1 404 Not Found\r\nContent-Length: 3\r\nConnection: close\r\n\r\nbad", + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ] { + let (_lease, observation) = fixture.observation(Source::Editor, PreviewSource::User); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + assert!(read_preview_request_line(&mut stream).unwrap().is_some()); + stream.write_all(response.as_bytes()).unwrap(); + }); + observation.observe(port).await; + server.join().unwrap(); + } + assert!(ready(&fixture.drained().await).is_empty()); +} + +#[tokio::test] +async fn preview_analytics_gui_start_schedules_without_waiting_and_registry_invalidates_old_instance( +) { + let fixture = Fixture::new(); + let registry = PreviewRegistry::default(); + let started = + start_local_game_preview_with_capture(&fixture.root, None, ®istry, fixture.capture()) + .unwrap(); + assert_eq!(registry.status().port, Some(started.port)); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if !ready(&fixture.drained().await).is_empty() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "GUI scheduled ready timeout" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + registry.stop(); + let (lease, old_observation) = fixture.observation(Source::Editor, PreviewSource::User); + let (old_preview, old_stop) = start_local_game_preview_for_project(&fixture.root).unwrap(); + registry.set_running_with_lease(old_preview.clone(), old_stop, Some(lease)); + let (lease, stopped_observation) = fixture.observation(Source::Editor, PreviewSource::User); + let (new_preview, new_stop) = start_local_game_preview_for_project(&fixture.root).unwrap(); + registry.set_running_with_lease(new_preview.clone(), new_stop, Some(lease)); + old_observation.observe(old_preview.port).await; + registry.stop(); + stopped_observation.observe(new_preview.port).await; + assert_eq!(ready(&fixture.drained().await).len(), 1); + + // 空入口仍遵循原有启动行为,采集跳过不能变成业务启动失败。 + fs::write(project_game_root(&fixture.root).join("index.html"), b"").unwrap(); + assert!(start_local_game_preview_with_capture( + &fixture.root, + None, + ®istry, + fixture.capture() + ) + .is_ok()); + registry.stop(); + assert_eq!(ready(&fixture.drained().await).len(), 1); +} + +#[tokio::test] +async fn preview_analytics_cancelled_direct_scope_does_not_publish_ready() { + let fixture = Fixture::new(); + let (_lease, observation) = fixture.observation(Source::Direct, PreviewSource::Agent); + let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let observation = observation.with_cancellation(Some(cancelled.clone())); + let (preview, stop) = start_local_game_preview_for_project(&fixture.root).unwrap(); + cancelled.store(true, std::sync::atomic::Ordering::Release); + observation.observe(preview.port).await; + let _ = stop.send(()); + assert!(ready(&fixture.drained().await).is_empty()); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index ced19bec9..7054fdad1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -1022,6 +1022,7 @@ pub(crate) async fn create_automatic_local_game_project_from_template( planning: Option, projects_root: Option, ) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); let identity = require_template_library_access().await?; let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?; let cache_root = template_cache_root(&app)?; @@ -1033,14 +1034,24 @@ pub(crate) async fn create_automatic_local_game_project_from_template( &identity, ) .await?; - with_validated_platform_session_identity(&identity, || { + let result = with_validated_platform_session_identity(&identity, || { create_project_from_installed_template_at( &projects_root, Path::new(&record.project_dir), name.as_deref(), planning.unwrap_or(false), ) - }) + }); + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::Template, + Some(template_id), + ); + } + result } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 4d28f041a..d98da777a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -289,6 +289,7 @@ import { } from './features/project-workspace/resourceReferences'; import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; +import { beginDirectRunAnalytics } from './services/clientAnalytics'; import { captureAgentRuntimeError } from './services/errorReporting'; import { currentPlatformSessionGeneration, @@ -6119,12 +6120,23 @@ export function App({ directTurnInput.userItem = effectiveUserItem; // 回复正文按条目身份从线程事件 / 历史切片进聊天,本地不再补一条, // 因此这里只等回合跑完,不接返回值。 - await withDirectCodexSessionRefresh(() => { - return directInvoke( - 'chat_with_game_creator_direct_codex', - directTurnInput, - ); - }); + const runAnalytics = beginDirectRunAnalytics( + directInvoke, + currentPlatformSessionGeneration, + ); + try { + await withDirectCodexSessionRefresh(() => { + return directInvoke( + 'chat_with_game_creator_direct_codex', + { + ...directTurnInput, + analyticsAttemptId: runAnalytics.nextAttempt(), + }, + ); + }); + } finally { + runAnalytics.settle(); + } // Rust already persisted the complete raw response items. Invalidate // any history snapshot captured before the turn completed. if (localProjectPathRef.current === directProjectPath) { @@ -8806,7 +8818,7 @@ export function App({ } const result = await invoke( 'start_local_game_preview', - { projectPath: nextProjectPath }, + { projectPath: nextProjectPath, previewSource: 'user' }, ); updateClientPreview(result); setPreviewStatus(`运行中:127.0.0.1:${result.port}`); @@ -9516,7 +9528,7 @@ export function App({ }); const previewResult = await invoke( 'start_local_game_preview', - { projectPath: nextProjectPath }, + { projectPath: nextProjectPath, previewSource: 'user' }, ); updateClientPreview(previewResult); setPreviewStatus(`运行中:127.0.0.1:${previewResult.port}`); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 6d016e649..9e188091c 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -95,8 +95,12 @@ export function WorkspaceLauncherShell({ }); const templateLibrary = useTemplateLibrary({ userId: currentUser.id, - onProjectCreated: async (result, isCurrent) => { - await homeProject.enterCreatedTemplateProject(result, isCurrent); + onProjectCreated: async (result, isCurrent, analytics) => { + await homeProject.enterCreatedTemplateProject( + result, + isCurrent, + analytics, + ); }, }); useEffect(() => { @@ -521,6 +525,17 @@ export function WorkspaceLauncherShell({ ); }, []); + function navigateLauncher(view: LauncherView) { + if ( + launcherView === 'project-development' && + view !== launcherView && + currentProjectContext + ) { + homeProject.leaveProjectAnalytics(); + } + setLauncherView(view); + } + function showLauncherNotice(title: string) { setLauncherNotice({ title, @@ -568,6 +583,7 @@ export function WorkspaceLauncherShell({ templateLibraryEnabled={templateLibrary.enabled} currentUser={currentUser} onLogout={() => { + homeProject.leaveProjectAnalytics(); resetLauncherHomeDraft(); accountWallet.resetWalletBalance(); onLogout(); @@ -575,7 +591,7 @@ export function WorkspaceLauncherShell({ onNoticeRequest={showLauncherNotice} onRechargeRequest={accountWallet.openRecharge} onRuntimeConfigOpen={() => setRuntimeConfigOpen(true)} - onViewChange={setLauncherView} + onViewChange={navigateLauncher} />
setLauncherView('projects')} + onProjectsOpen={() => navigateLauncher('projects')} onProjectOpen={(path) => { setProjectPath(path); void openProject(path, 'open'); @@ -712,8 +728,8 @@ export function WorkspaceLauncherShell({ ) } onManifestChange={syncActiveProjectManifest} - onHomeOpen={() => setLauncherView('home')} - onProjectsOpen={() => setLauncherView('projects')} + onHomeOpen={() => navigateLauncher('home')} + onProjectsOpen={() => navigateLauncher('projects')} supervisor={ (null); + const leaveProjectAnalytics = useCallback(() => { + const path = analyticsProjectPathRef.current; + analyticsProjectPathRef.current = null; + const invoke = resolveTauriInvoke(); + if (path && invoke) recordAnalyticsProjectLeave(invoke, path); + }, []); + useLayoutEffect(() => { + const lifecycle = lifecycleRef.current; + lifecycle.mounted = true; + const onPageHide = () => { + lifecycle.mounted = false; + lifecycle.generation += 1; + leaveProjectAnalytics(); + }; + window.addEventListener('pagehide', onPageHide); + return () => { + lifecycle.mounted = false; + lifecycle.generation += 1; + window.removeEventListener('pagehide', onPageHide); + // StrictMode 的同步 setup 重放不是用户离开;真实卸载才清理宿主登记。 + queueMicrotask(() => { + if (!lifecycle.mounted) leaveProjectAnalytics(); + }); + }; + }, [leaveProjectAnalytics]); const [projectPath, setProjectPathState] = useState(''); const [projectAction, setProjectAction] = useState< 'opening' | 'creating' | null @@ -242,7 +275,10 @@ export function useHomeProjectCreation({ async function enterProjectDevelopment( context: LauncherProjectContext, isCurrent: () => boolean = () => true, + analytics: ProjectOpenAnalytics = null, ) { + if (!lifecycleRef.current.mounted) return; + const generation = lifecycleRef.current.generation; const entryToken = (projectEntryTokenRef.current += 1); /** * 会话预览只认"内存 registry 里真的还在跑"的那一个(见 @@ -255,7 +291,12 @@ export function useHomeProjectCreation({ projectPath: context.projectPath, recordedPreview: context.manifest.preview ?? null, }); - if (entryToken !== projectEntryTokenRef.current || !isCurrent()) { + if ( + !lifecycleRef.current.mounted || + generation !== lifecycleRef.current.generation || + entryToken !== projectEntryTokenRef.current || + !isCurrent() + ) { // 更晚的一次进项目已经接管工作区:这一次的结果(预览与项目上下文)全部丢弃, // 否则慢请求后到会把新项目覆盖回旧项目。 return; @@ -278,6 +319,8 @@ export function useHomeProjectCreation({ setAgentChatProjectPath(context.projectPath); setLauncherView('project-development'); rememberRecentWorkspace(context.projectPath); + analyticsProjectPathRef.current = context.projectPath; + analytics?.record(context.projectPath); } async function readCurrentProjectRevision( @@ -316,6 +359,7 @@ export function useHomeProjectCreation({ prompt: string, attachments: HomeAttachmentDraft[], startMode: ProjectStartMode, + analytics: ProjectOpenAnalytics, ) { if (startMode === 'planning') { await invoke('set_design_agent_runtime_mode', { @@ -328,28 +372,32 @@ export function useHomeProjectCreation({ result.projectPath, attachments, ); - await enterProjectDevelopment({ - projectPath: result.projectPath, - projectName: - result.manifest.name || projectNameFromPath(result.projectPath), - projectKind: 'web', - manifest: result.manifest, - projectRevision: await readCurrentProjectRevision( - invoke, - result.projectPath, - ), - creationType, - startMode, - initialPrompt: - prompt.trim() || - (attachments.length > 0 - ? '用户上传了参考附件,等待后续补充需求。' - : ''), - attachments: importedAttachments, - recentRunStatus: null, - recentRunStopReason: null, - createdAt: Date.now(), - }); + await enterProjectDevelopment( + { + projectPath: result.projectPath, + projectName: + result.manifest.name || projectNameFromPath(result.projectPath), + projectKind: 'web', + manifest: result.manifest, + projectRevision: await readCurrentProjectRevision( + invoke, + result.projectPath, + ), + creationType, + startMode, + initialPrompt: + prompt.trim() || + (attachments.length > 0 + ? '用户上传了参考附件,等待后续补充需求。' + : ''), + attachments: importedAttachments, + recentRunStatus: null, + recentRunStopReason: null, + createdAt: Date.now(), + }, + undefined, + analytics, + ); resetLauncherHomeDraft(); } @@ -360,6 +408,7 @@ export function useHomeProjectCreation({ attachments: HomeAttachmentDraft[], startMode: ProjectStartMode, skipNonEmptyCheck = false, + analytics?: ProjectOpenAnalytics, ) { const trimmedProjectPath = validateProjectPath(nextProjectPath); if (!trimmedProjectPath) { @@ -370,6 +419,10 @@ export function useHomeProjectCreation({ setStatus('需要在 Tauri App 内运行'); return '需要在 Tauri App 内运行'; } + analytics = + analytics === undefined + ? beginProjectOpenAnalytics(invoke, 'create') + : analytics; setStatus('正在创建项目'); try { if (!skipNonEmptyCheck) { @@ -410,6 +463,7 @@ export function useHomeProjectCreation({ prompt, attachments, startMode, + analytics, ); setStatus('已创建项目,正在开始智能创作'); } catch (error) { @@ -433,6 +487,7 @@ export function useHomeProjectCreation({ // 首页输入框里已经写好的要求:打开已有项目时不能再丢掉(此前写死空串, // 用户写的内容既不发首轮也不进对话历史)。 initialPrompt = '', + analytics?: ProjectOpenAnalytics, ) { if (projectActionRef.current) { return; @@ -448,6 +503,10 @@ export function useHomeProjectCreation({ } projectActionRef.current = 'creating'; setProjectAction('creating'); + analytics = + analytics === undefined + ? beginProjectOpenAnalytics(invoke, 'create') + : analytics; setStatus('正在创建项目'); try { if (!skipNonEmptyCheck) { @@ -473,24 +532,28 @@ export function useHomeProjectCreation({ }, ); setStatus('已创建项目'); - await enterProjectDevelopment({ - projectPath: result.projectPath, - projectName: - result.manifest.name || projectNameFromPath(result.projectPath), - projectKind: 'web', - manifest: result.manifest, - projectRevision: await readCurrentProjectRevision( - invoke, - result.projectPath, - ), - creationType: null, - startMode: null, - initialPrompt, - attachments: [], - recentRunStatus: null, - recentRunStopReason: null, - createdAt: Date.now(), - }); + await enterProjectDevelopment( + { + projectPath: result.projectPath, + projectName: + result.manifest.name || projectNameFromPath(result.projectPath), + projectKind: 'web', + manifest: result.manifest, + projectRevision: await readCurrentProjectRevision( + invoke, + result.projectPath, + ), + creationType: null, + startMode: null, + initialPrompt, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + createdAt: Date.now(), + }, + undefined, + analytics, + ); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); } finally { @@ -508,6 +571,7 @@ export function useHomeProjectCreation({ async function enterCreatedTemplateProject( result: InitLocalProjectResult, isCurrent: () => boolean = () => true, + analytics: ProjectOpenAnalytics = null, ) { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -535,10 +599,15 @@ export function useHomeProjectCreation({ createdAt: Date.now(), }, isCurrent, + analytics, ); } - async function openProject(nextProjectPath: string, mode: 'open' | 'create') { + async function openProject( + nextProjectPath: string, + mode: 'open' | 'create', + analytics?: ProjectOpenAnalytics, + ) { if (mode === 'create') { await createProjectFromProjectPage(nextProjectPath); return; @@ -557,6 +626,10 @@ export function useHomeProjectCreation({ } projectActionRef.current = 'opening'; setProjectAction('opening'); + analytics = + analytics === undefined + ? beginProjectOpenAnalytics(invoke, 'recent') + : analytics; setStatus('正在打开'); try { const directoryStatus = await invoke( @@ -620,32 +693,37 @@ export function useHomeProjectCreation({ projectPath: trimmedProjectPath, }); setStatus('已打开项目'); - await enterProjectDevelopment({ - projectPath: trimmedProjectPath, - projectName: - directoryStatus.projectName || - projectNameFromPath(trimmedProjectPath), - projectKind: directoryStatus.isUnityProject - ? 'unity' - : directoryStatus.isCocosProject - ? 'cocos' - : directoryStatus.godotProjectRoot !== null && - directoryStatus.godotProjectRoot !== undefined - ? 'godot' - : 'web', - manifest: projectManifest, - projectRevision: await readCurrentProjectRevision( - invoke, - trimmedProjectPath, - ), - creationType: null, - startMode: runtimeMode?.activeRuntime === 'design' ? 'planning' : null, - initialPrompt: homeDraftPromptText(), - attachments: [], - recentRunStatus: directoryStatus.recentRunStatus, - recentRunStopReason: directoryStatus.recentRunStopReason, - createdAt: Date.now(), - }); + await enterProjectDevelopment( + { + projectPath: trimmedProjectPath, + projectName: + directoryStatus.projectName || + projectNameFromPath(trimmedProjectPath), + projectKind: directoryStatus.isUnityProject + ? 'unity' + : directoryStatus.isCocosProject + ? 'cocos' + : directoryStatus.godotProjectRoot !== null && + directoryStatus.godotProjectRoot !== undefined + ? 'godot' + : 'web', + manifest: projectManifest, + projectRevision: await readCurrentProjectRevision( + invoke, + trimmedProjectPath, + ), + creationType: null, + startMode: + runtimeMode?.activeRuntime === 'design' ? 'planning' : null, + initialPrompt: homeDraftPromptText(), + attachments: [], + recentRunStatus: directoryStatus.recentRunStatus, + recentRunStopReason: directoryStatus.recentRunStopReason, + createdAt: Date.now(), + }, + undefined, + analytics, + ); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); } finally { @@ -698,6 +776,7 @@ export function useHomeProjectCreation({ if (!invoke) { throw new Error('需要在 Tauri App 内运行'); } + const analytics = beginProjectOpenAnalytics(invoke, 'create'); const selectedPath = await invoke( 'pick_local_project_directory', projectPath.trim() ? { initialPath: projectPath.trim() } : undefined, @@ -711,6 +790,8 @@ export function useHomeProjectCreation({ draft.prompt, draft.attachments, startMode, + false, + analytics, ); } @@ -742,6 +823,7 @@ export function useHomeProjectCreation({ if (!invoke) { throw new Error('需要在陶泥儿客户端内运行'); } + const analytics = beginProjectOpenAnalytics(invoke, 'create'); const attempt = (homeCreationAttemptRef.current += 1); const isCurrentAttempt = () => homeCreationAttemptRef.current === attempt; const operation = createClientOperation( @@ -843,6 +925,7 @@ export function useHomeProjectCreation({ draft.prompt, draft.attachments, startMode, + analytics, ); setHomeCreationOperation( transitionClientOperation(operation, 'success', { @@ -904,6 +987,7 @@ export function useHomeProjectCreation({ } projectActionRef.current = 'opening'; setProjectAction('opening'); + const analytics = beginProjectOpenAnalytics(invoke, 'picker'); setStatus('正在选择项目'); try { const selectedPath = await invoke( @@ -917,7 +1001,7 @@ export function useHomeProjectCreation({ setProjectPath(selectedPath); projectActionRef.current = null; setProjectAction(null); - await openProject(selectedPath, 'open'); + await openProject(selectedPath, 'open', analytics); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); } finally { @@ -939,6 +1023,7 @@ export function useHomeProjectCreation({ } projectActionRef.current = 'creating'; setProjectAction('creating'); + const analytics = beginProjectOpenAnalytics(invoke, 'create'); setStatus('正在选择新项目文件夹'); try { const selectedPath = await invoke( @@ -956,6 +1041,7 @@ export function useHomeProjectCreation({ selectedPath, false, homeDraftPromptText(), + analytics, ); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); @@ -993,6 +1079,7 @@ export function useHomeProjectCreation({ } return { + leaveProjectAnalytics, projectPath, setProjectPath, currentProjectContext, diff --git a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts index f2f835f09..2d38e27f9 100644 --- a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts +++ b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts @@ -10,6 +10,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { resolveTauriInvoke } from '../../app/tauri'; import type { InitLocalProjectResult } from '../../app/types'; +import { + beginProjectOpenAnalytics, + type ProjectOpenAnalytics, +} from '../../services/clientAnalytics'; import { currentPlatformSessionGeneration, subscribePlatformSessionGeneration, @@ -38,6 +42,7 @@ type UseTemplateLibraryOptions = { onProjectCreated: ( result: InitLocalProjectResult, isCurrent: () => boolean, + analytics: ProjectOpenAnalytics, ) => Promise | void; }; @@ -247,6 +252,7 @@ export function useTemplateLibrary({ if (!invoke) { throw new Error('需要在陶泥儿客户端内运行'); } + const analytics = beginProjectOpenAnalytics(invoke, 'create'); try { if (needsTemplateDownload(template)) { await downloadTemplate(template); @@ -268,7 +274,7 @@ export function useTemplateLibrary({ }, ); if (!isCurrent()) throw new Error('登录态已变化,模板操作已停止'); - await onProjectCreated(result, isCurrent); + await onProjectCreated(result, isCurrent, analytics); if (isCurrent()) setNotice(`已用模板「${template.title}」创建项目`); return result; } catch (nextError) { diff --git a/apps/ai-game-creator-shell/src/services/clientAnalytics.ts b/apps/ai-game-creator-shell/src/services/clientAnalytics.ts new file mode 100644 index 000000000..7e5c928c6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/clientAnalytics.ts @@ -0,0 +1,134 @@ +import type { TauriInvoke } from '../app/types'; + +// 一个 run 可因自动认证刷新调用多次 native,只确认最后一次尝试。 +export function beginDirectRunAnalytics( + invoke: TauriInvoke, + currentGeneration: () => number, +) { + const generation = currentGeneration(); + let attemptId: string | undefined; + let settled = false; + return { + nextAttempt() { + attemptId = undefined; + try { + attemptId = crypto.randomUUID(); + } catch { + // 最后一次 UUID 失败不能留下前一次失败候选的标识。 + } + return attemptId; + }, + settle() { + if (settled) return; + settled = true; + if (!attemptId) return; + try { + void invoke('settle_direct_run_analytics', { + attemptId, + discard: currentGeneration() !== generation, + }).catch(() => undefined); + } catch { + // 不等待埋点,不改变调用链的成功、失败和取消结果。 + } + }, + }; +} + +// 宿主签发的无凭据上下文:前端只传回原值,不从当前登录态补身份。 +type AnalyticsContext = { + route: { user_id: string | null; destination_origin: string | null }; + editor_session_id: string; + client_version: string; +}; + +export type ProjectOpenAnalytics = ReturnType; + +export function beginProjectOpenAnalytics( + invoke: TauriInvoke, + openSource: 'create' | 'picker' | 'recent', +) { + try { + const operationId = crypto.randomUUID(); + const context = invoke( + 'capture_analytics_context', + ).catch(() => null); + let recorded = false; + return { + record(projectPath: string) { + if (recorded) return; + recorded = true; + const eventTime = new Date().toISOString(); + void context + .then((captured) => { + if (!captured) return; + return invoke('record_analytics_project_open', { + context: captured, + projectPath, + operationId, + openSource, + eventTime, + }); + }) + .catch(() => undefined); + }, + }; + } catch { + // UUID、桥接与写入失败都不能改变业务操作结果。 + return null; + } +} + +export function recordAnalyticsProjectLeave( + invoke: TauriInvoke, + projectPath: string, +) { + try { + void invoke('record_analytics_project_leave', { projectPath }).catch( + () => undefined, + ); + } catch { + // 离开项目不能等待或依赖埋点。 + } +} + +// 保存开始冻结身份,完成时冻结时间;观察器不能影响保存结果。 +export function beginUiSaveAnalytics( + invoke: TauriInvoke, + projectPath: string, + saveSource: 'manual' | 'auto', +) { + try { + const operationId = crypto.randomUUID(); + const context = invoke( + 'capture_analytics_context', + ).catch(() => null); + let recorded = false; + return { + record(changed: boolean) { + if (recorded) return; + recorded = true; + if (saveSource === 'auto' && !changed) return; + try { + const eventTime = new Date().toISOString(); + void context + .then((captured) => { + if (!captured) return; + return invoke('record_analytics_ui_save', { + context: captured, + projectPath, + operationId, + saveSource, + changed, + eventTime, + }); + }) + .catch(() => undefined); + } catch { + // 时间或桥接失败仅丢弃本条埋点。 + } + }, + }; + } catch { + return null; + } +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index 2d2ccd165..6a2d84664 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -50,6 +50,7 @@ import { type NodeTransformOptions, useUiEditorState, } from '../../features/ui-editor/useUiEditorState'; +import { beginUiSaveAnalytics } from '../../services/clientAnalytics'; import { cancelLocalProjectResourcePreviewScope, createProjectResourcePreviewRequestId, @@ -1216,7 +1217,7 @@ export function useUiEditorSession( if (separationResult === null) throw new Error('自动切分素材没有返回结果'); const completedResult = separationResult as SeparationDTO; - if (!(await save({ allowDuringSeparation: true }))) { + if (!(await save({ allowDuringSeparation: true, saveSource: 'auto' }))) { throw new Error( '自动切分素材结果已写入编辑器,但 State 保存失败;sidecar 已保留,可继续恢复。', ); @@ -1375,7 +1376,10 @@ export function useUiEditorSession( } } - async function save(options?: { allowDuringSeparation?: boolean }) { + async function save(options?: { + allowDuringSeparation?: boolean; + saveSource?: 'manual' | 'auto'; + }) { const allowDuringSeparation = options?.allowDuringSeparation === true; if ( !resourceId || @@ -1389,6 +1393,11 @@ export function useUiEditorSession( ) { return false; } + const analytics = beginUiSaveAnalytics( + invoke, + projectPath, + options?.saveSource ?? 'manual', + ); setSaveError(null); setGenerateError(null); setIsSaving(true); @@ -1406,6 +1415,7 @@ export function useUiEditorSession( } setPersistedRevision(result.revision); setSavedStateSignature(snapshotSignature); + analytics?.record(result.status === 'saved'); return true; }); } catch { @@ -1457,6 +1467,7 @@ export function useUiEditorSession( ) { return null; } + const analytics = beginUiSaveAnalytics(invoke, projectPath, 'manual'); setSaveError(null); setGenerateError(null); setIsSaving(true); @@ -1475,7 +1486,9 @@ export function useUiEditorSession( } setPersistedRevision(saved.revision); setSavedStateSignature(snapshotSignature); - return await stateStore.generateCode(resourceId); + const generated = await stateStore.generateCode(resourceId); + analytics?.record(saved.status === 'saved'); + return generated; }); } catch (cause) { setGenerateError(cause instanceof Error ? cause.message : String(cause)); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts index fa54a42e3..28d3bd7d6 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts @@ -2488,6 +2488,7 @@ export function registerProjectCommandTests() { ).not.toBeNull(); expect(screen.queryByTitle('本地游戏预览')).toBeNull(); expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + previewSource: 'user', projectPath: '/tmp/authorized-game', }); expect(invoke).not.toHaveBeenCalledWith('activate_local_game_preview', { @@ -2587,6 +2588,7 @@ export function registerProjectCommandTests() { ), ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + previewSource: 'user', projectPath: '/tmp/authorized-game', }); }); @@ -2790,10 +2792,12 @@ export function registerProjectCommandTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + previewSource: 'user', projectPath: '/tmp/authorized-game', }); }); expect(invoke).not.toHaveBeenCalledWith('start_local_game_preview', { + previewSource: 'user', projectPath: '/tmp/other-game', }); expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { @@ -3035,6 +3039,7 @@ export function registerProjectCommandTests() { commandId: 'game.static_smoke', }); expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + previewSource: 'user', projectPath: '/tmp/authorized-game', }); expect(invoke).not.toHaveBeenCalledWith('activate_local_game_preview', { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index ed2ba1494..129f6ef2e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -7208,6 +7208,7 @@ export function registerProjectSupervisorSurfaceTests() { ).not.toBeNull(); expect(handled).toHaveBeenCalledWith(7); expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + previewSource: 'user', projectPath, }); expect(screen.queryByText('game.run_local')).toBeNull(); @@ -7257,6 +7258,7 @@ export function registerProjectSupervisorSurfaceTests() { ), ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + previewSource: 'user', projectPath, }); expect(invoke).not.toHaveBeenCalledWith('run_limited_local_command', { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts index 22fe7f8e6..5e71a4dd0 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts @@ -918,6 +918,7 @@ export function registerProjectToolsAndPreviewTests() { expect(await screen.findByText('预览已停止。')).not.toBeNull(); expect(screen.getByText('preview: 已停止')).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + previewSource: 'user', projectPath: '/tmp/authorized-game', }); expect(invoke).toHaveBeenCalledWith('activate_local_game_preview', { diff --git a/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx b/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx new file mode 100644 index 000000000..df56e22a9 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx @@ -0,0 +1,333 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from '@testing-library/react'; +import { StrictMode } from 'react'; +import { afterEach, expect, it, vi } from 'vitest'; + +import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { TauriInvoke } from '../src/app/types'; +import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation'; +import { + beginProjectOpenAnalytics, + beginUiSaveAnalytics, +} from '../src/services/clientAnalytics'; + +const context = { + route: { user_id: 'user-a', destination_origin: 'https://a.example' }, + editor_session_id: 'session-a', + client_version: '1', +}; +function deferred() { + let resolve!: (value: T) => void; + return { + promise: new Promise((done) => { + resolve = done; + }), + resolve: (value: T) => resolve(value), + }; +} +afterEach(() => { + cleanup(); + delete window.__TAURI__; + vi.restoreAllMocks(); +}); + +function mount( + capture: () => Promise = async () => context, + preview: (path: string) => Promise = async () => null, +) { + vi.spyOn(crypto, 'randomUUID').mockReturnValue( + '12345678-1234-4234-8234-123456789012', + ); + const manifest = createGameCreationAppManifest('project', '项目'); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'capture_analytics_context') return capture(); + if (command === 'get_local_game_preview_status') + return preview(String(args?.projectPath)); + if (command === 'inspect_local_project_directory') + return { exists: true, isDirectory: true, isGameCreatorProject: true }; + if (command === 'get_local_game_manifest') return manifest; + if (command === 'pick_local_project_directory') return 'C:/picker'; + if (command === 'init_local_game_project') + return { projectPath: args?.projectPath, manifest }; + if (command === 'get_local_game_project_revision') return { revision: 1 }; + return null; + }, + ); + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + const hook = renderHook( + () => + useHomeProjectCreation({ + setStatus: vi.fn(), + setLauncherView: vi.fn(), + setAgentChatProjectPath: vi.fn(), + rememberRecentWorkspace: vi.fn(), + }), + { wrapper: StrictMode }, + ); + return { + ...hook, + invoke, + manifest, + records: () => + invoke.mock.calls.filter( + ([command]) => command === 'record_analytics_project_open', + ), + }; +} + +it.each(['recent', 'picker', 'create'] as const)( + '成功进入项目记录真实 %s 来源', + async (source) => { + const { result, records } = mount(); + await act(async () => { + if (source === 'picker') await result.current.pickAndOpenProject(); + else + await result.current.openProject( + 'C:/project', + source === 'create' ? 'create' : 'open', + ); + }); + expect(records()).toHaveLength(1); + expect(records()[0][1]).toMatchObject({ context, openSource: source }); + }, +); + +it('身份抓取挂起不阻塞打开,成功时间在抓取完成前冻结', async () => { + const captured = deferred(); + const { result, records } = mount(() => captured.promise); + await act(async () => { + await result.current.openProject('C:/project', 'open'); + }); + expect(result.current.currentProjectContext?.projectPath).toBe('C:/project'); + const latestSuccessTime = Date.now(); + expect(records()).toHaveLength(0); + await act(async () => { + captured.resolve(context); + }); + expect(records()).toHaveLength(1); + expect(Date.parse(String(records()[0][1]?.eventTime))).toBeLessThanOrEqual( + latestSuccessTime, + ); +}); + +it('预览核验挂起时卸载,不记录从未进入的工作区', async () => { + const preview = deferred(); + const { result, unmount, records, invoke } = mount( + undefined, + () => preview.promise, + ); + let pending!: Promise; + await act(async () => { + pending = result.current.openProject('C:/old', 'open'); + }); + unmount(); + await act(async () => { + preview.resolve(null); + await pending; + }); + expect(records()).toHaveLength(0); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'record_analytics_project_leave', + ), + ).toHaveLength(0); +}); + +it('StrictMode 重放不产生离开,真正卸载清除已进入的项目', async () => { + const { result, unmount, invoke } = mount(); + await act(async () => { + await result.current.openProject('C:/project', 'open'); + }); + const leaves = () => + invoke.mock.calls.filter( + ([command]) => command === 'record_analytics_project_leave', + ); + expect(leaves()).toHaveLength(0); + unmount(); + await act(async () => {}); + expect(leaves()).toEqual([ + ['record_analytics_project_leave', { projectPath: 'C:/project' }], + ]); +}); + +it('页面关闭通知尽力清理,随后卸载不会重复通知', async () => { + const { result, unmount, invoke } = mount(); + await act(async () => { + await result.current.openProject('C:/project', 'open'); + }); + window.dispatchEvent(new Event('pagehide')); + unmount(); + await act(async () => {}); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'record_analytics_project_leave', + ), + ).toHaveLength(1); +}); + +it('同项目再次显式打开是新操作,重渲染不会产生额外打开', async () => { + const { result, rerender, records } = mount(); + vi.mocked(crypto.randomUUID) + .mockReturnValueOnce('12345678-1234-4234-8234-123456789011') + .mockReturnValueOnce('12345678-1234-4234-8234-123456789012'); + await act(async () => { + await result.current.openProject('C:/project', 'open'); + }); + rerender(); + expect(records()).toHaveLength(1); + await act(async () => { + await result.current.openProject('C:/project', 'open'); + }); + expect(records()).toHaveLength(2); + expect(records()[0][1]?.operationId).not.toBe(records()[1][1]?.operationId); +}); + +it('身份抓取和记录写入失败静默,不改变成功打开', async () => { + const { result, records, invoke } = mount(async () => { + throw new Error('offline'); + }); + await act(async () => { + await result.current.openProject('C:/project', 'open'); + }); + expect(result.current.currentProjectContext?.projectPath).toBe('C:/project'); + expect(records()).toHaveLength(0); + const failing = vi.fn(async (command: string) => { + if (command === 'capture_analytics_context') return context; + throw new Error('write'); + }); + beginProjectOpenAnalytics(failing as TauriInvoke, 'recent')?.record( + 'C:/project', + ); + await act(async () => {}); + expect(failing).toHaveBeenCalledWith( + 'record_analytics_project_open', + expect.anything(), + ); + expect(invoke).toHaveBeenCalledWith('capture_analytics_context'); +}); + +it('较晚的导航获采纳,旧预览核验迟到不记录打开', async () => { + const oldPreview = deferred(); + const { result, invoke, manifest, records } = mount( + undefined, + async (path) => (path === 'C:/old' ? oldPreview.promise : null), + ); + let old!: Promise; + await act(async () => { + old = result.current.enterCreatedTemplateProject( + { projectPath: 'C:/old', manifestPath: '', manifest }, + () => true, + beginProjectOpenAnalytics(invoke as TauriInvoke, 'create'), + ); + }); + await act(async () => { + await result.current.enterCreatedTemplateProject( + { projectPath: 'C:/new', manifestPath: '', manifest }, + () => true, + beginProjectOpenAnalytics(invoke as TauriInvoke, 'create'), + ); + oldPreview.resolve(null); + await old; + }); + expect(result.current.currentProjectContext?.projectPath).toBe('C:/new'); + expect(records().map(([, args]) => args?.projectPath)).toEqual(['C:/new']); +}); + +it('模板权限失效后不记录被舍弃的导航,同次成功通知只记一次', async () => { + const pending = deferred(); + const { result, invoke, manifest, records } = mount( + undefined, + () => pending.promise, + ); + let current = true; + let entry!: Promise; + const action = beginProjectOpenAnalytics(invoke as TauriInvoke, 'create'); + await act(async () => { + entry = result.current.enterCreatedTemplateProject( + { projectPath: 'C:/old', manifestPath: '', manifest }, + () => current, + action, + ); + }); + await act(async () => { + current = false; + pending.resolve(null); + await entry; + }); + expect(records()).toHaveLength(0); + action?.record('C:/accepted'); + action?.record('C:/accepted'); + await act(async () => {}); + expect(records()).toHaveLength(1); +}); + +it('freezes UI save identity and completion time while context delivery is delayed', async () => { + const captured = deferred(); + const invoke = vi.fn(async (command: string) => + command === 'capture_analytics_context' ? captured.promise : undefined, + ); + const now = vi + .spyOn(Date.prototype, 'toISOString') + .mockReturnValue('2026-09-21T01:00:00.000Z'); + const analytics = beginUiSaveAnalytics( + invoke as TauriInvoke, + 'C:/project', + 'manual', + ); + analytics?.record(false); + now.mockReturnValue('2026-09-21T02:00:00.000Z'); + analytics?.record(true); + captured.resolve(context); + await act(async () => { + await captured.promise; + }); + const calls = invoke.mock.calls.filter( + ([command]) => command === 'record_analytics_ui_save', + ); + expect(calls).toHaveLength(1); + expect(invoke).toHaveBeenCalledWith( + 'record_analytics_ui_save', + expect.objectContaining({ + context, + changed: false, + eventTime: '2026-09-21T01:00:00.000Z', + }), + ); +}); + +it('isolates UI save analytics UUID and sync/async bridge failures', async () => { + const throwing = vi.fn(() => { + throw new Error('bridge unavailable'); + }); + expect(() => + beginUiSaveAnalytics( + throwing as TauriInvoke, + 'C:/project', + 'manual', + )?.record(true), + ).not.toThrow(); + const rejected = vi.fn().mockRejectedValue(new Error('bridge rejected')); + beginUiSaveAnalytics(rejected as TauriInvoke, 'C:/project', 'manual')?.record( + true, + ); + const recordFailure = vi.fn((command: string) => { + if (command === 'capture_analytics_context') + return Promise.resolve(context); + throw new Error('record failed'); + }); + beginUiSaveAnalytics( + recordFailure as TauriInvoke, + 'C:/project', + 'manual', + )?.record(true); + vi.spyOn(crypto, 'randomUUID').mockImplementation(() => { + throw new Error('no UUID'); + }); + expect( + beginUiSaveAnalytics(rejected as TauriInvoke, 'C:/project', 'manual'), + ).toBeNull(); + await act(async () => { + await Promise.resolve(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directRunAnalytics.test.ts b/apps/ai-game-creator-shell/tests/directRunAnalytics.test.ts new file mode 100644 index 000000000..245e2ebe8 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directRunAnalytics.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest'; + +import type { TauriInvoke } from '../src/app/types'; +import { beginDirectRunAnalytics } from '../src/services/clientAnalytics'; + +afterEach(() => vi.restoreAllMocks()); + +it('自动重试保持业务回合,只确认最后一次尝试且确认不等待桥接', async () => { + const first = '11111111-1111-4111-8111-111111111111'; + const last = '22222222-2222-4222-8222-222222222222'; + vi.spyOn(crypto, 'randomUUID') + .mockReturnValueOnce(first) + .mockReturnValueOnce(last); + const invoke = vi.fn(() => new Promise(() => {})); + const analytics = beginDirectRunAnalytics(invoke as TauriInvoke, () => 1); + const attempts: string[] = []; + const operation = async () => { + attempts.push(analytics.nextAttempt()!); + if (attempts.length === 1) throw new Error('authentication-required'); + return '完成'; + }; + let result: string; + try { + result = await operation().catch(() => operation()); + expect(invoke).not.toHaveBeenCalled(); + } finally { + analytics.settle(); + } + expect(result).toBe('完成'); + expect(attempts).toEqual([first, last]); + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledWith('settle_direct_run_analytics', { + attemptId: last, + discard: false, + }); + analytics.settle(); + expect(invoke).toHaveBeenCalledTimes(1); +}); + +it('最终 UUID 失败不能回退确认第一次失败', () => { + vi.spyOn(crypto, 'randomUUID') + .mockReturnValueOnce('11111111-1111-4111-8111-111111111111') + .mockImplementationOnce(() => { + throw new Error('UUID unavailable'); + }); + const invoke = vi.fn(); + const analytics = beginDirectRunAnalytics(invoke as TauriInvoke, () => 1); + expect(analytics.nextAttempt()).toBeDefined(); + expect(analytics.nextAttempt()).toBeUndefined(); + analytics.settle(); + expect(invoke).not.toHaveBeenCalled(); +}); + +it('账号代次变化只丢弃候选,不提交成功失败结论', () => { + let generation = 1; + const invoke = vi.fn(async () => undefined); + const analytics = beginDirectRunAnalytics( + invoke as TauriInvoke, + () => generation, + ); + const attemptId = analytics.nextAttempt(); + generation = 2; + analytics.settle(); + expect(invoke).toHaveBeenCalledWith('settle_direct_run_analytics', { + attemptId, + discard: true, + }); +}); + +it.each(['sync', 'async'])( + '确认桥接 %s 失败不会覆盖最终业务错误', + async (mode) => { + const invoke = vi.fn(() => { + if (mode === 'sync') throw new Error('bridge'); + return Promise.reject(new Error('bridge')); + }); + const analytics = beginDirectRunAnalytics(invoke as TauriInvoke, () => 1); + const failure = new Error('最终执行失败'); + const operation = async () => { + try { + analytics.nextAttempt(); + throw failure; + } finally { + analytics.settle(); + } + }; + await expect(operation()).rejects.toBe(failure); + expect(invoke).toHaveBeenCalledTimes(1); + }, +); diff --git a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx index 44b64ae0a..1bee3d950 100644 --- a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx +++ b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx @@ -14,7 +14,7 @@ afterEach(() => { function mount(preflight: () => Promise) { const calls: string[] = []; const invoke = vi.fn(async (command: string) => { - calls.push(command); + if (command !== 'capture_analytics_context') calls.push(command); if (command === 'preflight_web_game_creation') return preflight(); if (command === 'suggest_automatic_project_name') return '预检项目'; if (command === 'create_automatic_local_game_project') diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index d631c5051..503d02003 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -747,4 +747,129 @@ describe('UiEditorPage', () => { '资源已在别处更新;请重新加载后再保存。', ); }); + it.each([ + ['manual', 'saved', 1], + ['manual', 'unchanged', 1], + ['auto', 'saved', 1], + ['auto', 'unchanged', 0], + ['manual', 'conflict', 0], + ['manual', 'failure', 0], + ] as const)( + 'records the real %s save outcome %s', + async (saveSource, status, count) => { + vi.mocked(invoke).mockImplementation(async (command) => + command === 'capture_analytics_context' + ? { + route: { + user_id: 'A', + destination_origin: 'https://example.com', + }, + editor_session_id: 'session', + client_version: '1', + } + : undefined, + ); + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), + save: + status === 'failure' + ? vi.fn().mockRejectedValue(new Error('write failed')) + : vi.fn().mockResolvedValue({ + status, + revision: 1, + state: EMPTY_SNAPSHOT.state, + committedProjectRevision: 3, + }), + generateCode: vi.fn(), + }; + const hook = renderHook(() => + useUiEditorSession('/tmp/ui-editor', 'ui-resource', stateStore), + ); + await waitFor(() => + expect(hook.result.current.save.isLoading).toBe(false), + ); + await act(async () => { + await hook.result.current.save.save({ saveSource }); + }); + const calls = vi + .mocked(invoke) + .mock.calls.filter( + ([command]) => command === 'record_analytics_ui_save', + ); + expect(calls).toHaveLength(count); + if (count) + expect(calls[0][1]).toMatchObject({ + projectPath: '/tmp/ui-editor', + saveSource, + changed: status === 'saved', + }); + }, + ); + + it.each([true, false])( + 'records combined save only after successful code generation: %s', + async (success) => { + vi.mocked(invoke).mockImplementation(async (command) => + command === 'capture_analytics_context' + ? { + route: { user_id: 'A', destination_origin: null }, + editor_session_id: 'session', + client_version: '1', + } + : undefined, + ); + let complete!: () => void; + const generation = new Promise((resolve) => { + complete = resolve; + }); + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), + save: vi.fn().mockResolvedValue({ + status: 'saved', + revision: 1, + state: EMPTY_SNAPSHOT.state, + committedProjectRevision: 3, + }), + generateCode: vi.fn(async () => { + await generation; + if (!success) throw new Error('generation failed'); + return { + relativePath: 'ui/generated.js', + treeExports: [], + treeCount: 0, + nodeCount: 0, + }; + }), + }; + const hook = renderHook(() => + useUiEditorSession('/tmp/ui-editor', 'ui-resource', stateStore), + ); + await waitFor(() => + expect(hook.result.current.save.isLoading).toBe(false), + ); + let operation!: Promise; + await act(async () => { + operation = hook.result.current.save.saveAndGenerateCode(); + await Promise.resolve(); + }); + expect( + vi + .mocked(invoke) + .mock.calls.filter( + ([command]) => command === 'record_analytics_ui_save', + ), + ).toHaveLength(0); + await act(async () => { + complete(); + await operation; + }); + expect( + vi + .mocked(invoke) + .mock.calls.filter( + ([command]) => command === 'record_analytics_ui_save', + ), + ).toHaveLength(success ? 1 : 0); + }, + ); }); diff --git a/docs/README.md b/docs/README.md index 5fe567db2..a35b609ce 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,7 +25,7 @@ ## AI 游戏创作与 Agent Runtime -- [客户端本地埋点与主站入库契约](./technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md):当前埋点方案,尚未实施;本期只做事件采集和明文 JSONL 持久化,不做加密或上传,待定稿建议在文内单列。 +- [客户端本地埋点与主站入库契约](./technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md):本期本地采集已验收,合同与本地队列、会话窗口与项目、策划阶段成果、首次提交、两类 Agent run、Direct 文件/补丁成果、GUI 人工编辑、checkpoint 与 UI 保存,以及正式 Web 预览 ready 已验收;同一宿主组件链路关联已验证,不要求逐项覆盖全部资源操作,合计 12 类启用事件;本期只做事件采集和明文 JSONL 持久化,不做加密或上传。 - [AGC 资源 kind 枚举化契约](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-15-gamecreationapp-资源-kind-枚举化当前权威口径):GameCreationApp 资源 kind 的 Rust enum、ts-rs 绑定、Unknown 可观测性和 shell 内重构边界。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1c1bd5e69..d31ff46b9 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,9 +2,17 @@ ## 2026-09-21 客户端埋点方案进入团队共享文档 +- 本期验收完成:原始需求与已确认口径的 12 类事件入口已核对,同一 writer/session/project/goal 的宿主组件链路通过真实文件、HTTP、checkpoint 与 JSONL 关联验证;最终 51 项 Rust、46 项前端测试和类型/格式/文档检查通过。仅测试辅助模拟创建投递、run 结果和构建产物,不宣称完整 GUI/Provider 端到端验证;未提交或发布。禁止把全部资源操作逐项接线重新当作本期必做范围。 +- 最新口径:共 12 类启用事件;策划审批通过后实际进入下一阶段并成功持久化,复用 project_revision_created,revision_id=design::、source=design_agent、revision_source=agent、change_kind=design_document。同会话同目标阶段幂等;不严格校验文档版本/差异,阶段内文件修改不逐次采集,重开不补历史。不新增独立策划进度事件或审批、澄清状态字段。Direct 文件/补丁、人工编辑、UI 成果、预览与保存已有定向验收,基础事件入口已核对,同一宿主组件链路验收已通过。 +- 两类 Agent run 元数据随真实受理保存,后台维护项目累计观测重试与双 Agent 当前终态槽位;重放不新建,恢复保留原身份且耗时未知,取消/不确定不伪造失败。Direct 自动认证刷新只确认最后一次原生尝试的有界内存候选,账号代次变化丢弃;缺失不回退旧失败,不为观测增加业务写盘等待。运行结果已通过独立验收,真实付费 Provider/完整 GUI run 尚未 smoke,现有 Direct 合同中断恢复行为未改变。 +- Direct 宿主文件写入/正式补丁仅在已知成果内容变化且原事务 revision 成功提交后记成果,原 run 用户归属不变,末尾 projection 不重复记。当前 session 内存关联最新可信成果到 run;缺证据为 null,不据全局 fingerprint 推断作者。真实 writer、bundled patch 执行器和定向测试已通过。 +- GUI 人工文件写入区分真实变化和显式保存,删除不记保存;完整项目 checkpoint 按真实 checkpoint_id 记保存。UI State 仅 Saved 记成果;手动 Saved/Unchanged 可记保存,自动保存仅 Saved,保存并生成需全操作成功。起点冻结身份,埋点失败不影响业务。63 项 Rust、41 项前端测试及独立验收通过;无完整 GUI 跨层保存 smoke。旧 Runtime 开发/CLI 文件及 UI workflow 不因存在代码就纳入正式 GUI 必需采集。 +- 正式 Web preview_ready 由 GUI 用户持续预览和 Direct 临时浏览器预览接入,冻结原身份、版本与实例;异步2秒loopback GET禁代理/重定向,原入口及响应非空、版本/实例仍匹配才记录。实例停止/替换、验证结束或取消后丢弃迟到结果;可访问不等于JS/游戏验证成功。合并后78项Rust、104项前端测试及独立验收通过,未调用真实Provider或跑完整Chrome双端验证。资源操作全面接线计划已撤销;现有采集只表示已观测变化,不代表全部资源操作或项目全部修订。 +- 首次提交按本地观测口径每个新项目最多一条:真实受理候选在后台持久消费 `.agent/analytics-goal.json` 资格后投递。资格跨批次清理保留,旧项目不初始化;此前候选丢失时允许后续真实受理消费,使用后者自己的用户与时间,不宣称绝对首次。消费后事件丢失可零条,重放与恢复不补历史;不得为埋点扫描双 Agent 完整历史或阻塞业务写盘。 + - 当前合同唯一维护入口为[客户端本地埋点与主站入库契约](../../technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md),原始需求作为仓库内历史来源保存;后续里程碑规范与实施计划放在 `docs/project-memory/plans/`。 - 已确认本期只做本地事件采集和明文 JSONL 持久化,不做加密与上传;一个项目对应一个目标,事件按业务节点采集,5 分钟封存,7 天或 20 MiB 清理。后续上传周期为 15 分钟。 -- 当前尚未实施,参数建议及剩余口径见方案第 11 节;文档入库不表示这些建议已获确认或功能已上线。 +- 已按技术负责人授权开始实施:合同与本地队列、会话窗口与项目接入、策划阶段成果、首次提交及两类 Agent run 已实现并经独立审查;定向测试、生产编译和前序 GUI 启停证据统一见主规范第 12 节。不得宣称完整产品采集已上线。 ## 2026-09-21 模板库线上产物对齐仓库源:递增版本重发 + 说明文档纳入发布 diff --git a/docs/technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md b/docs/technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md index 48b4616f3..2348ddbc8 100644 --- a/docs/technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md +++ b/docs/technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md @@ -1,7 +1,7 @@ # 客户端本地埋点与主站入库契约 -Version: 0.10 -Status: 当前方案,已确认口径作为实施依据;第 11 节保留待定稿建议,尚未实施 +Version: 0.23 +Status: 本期本地采集已实现并通过验收;不加密、不上传,证据与未验证范围见第 12 节 Date: 2026-09-21 需求来源:[Game Agent 埋点设计原始方案](./【需求来源】GameAgent埋点设计原始方案-2026-09-05.md);原始方案与后续已确认决策有差异时,以本文为准。 @@ -13,7 +13,7 @@ Date: 2026-09-21 优先级: -- 必须项:字段合同、身份与因果关联、原方案 13 类事件逐项对照(采集其中 12 类,暂不采集 session_timeout)、新增策划进度快照事件、本地未闭合会话标记、本地 JSONL 持久化、静默失败、定向验证。本版合计启用 13 类事件。 +- 必须项:字段合同、身份与因果关联、原方案 13 类事件逐项对照(采集其中 12 类,暂不采集 session_timeout)、策划阶段推进复用成果事件、本地未闭合会话标记、本地 JSONL 持久化、静默失败、定向验证。本版合计启用 12 类事件。 - 风险项:项目目标身份稳定性、跨账号在途操作、多窗口前台时间、崩溃未闭合区间、revision 与保存事实、重复回调。 - 可选项:无;本阶段不扩充分析维度或细粒度点击事件。 @@ -29,13 +29,13 @@ Date: 2026-09-21 2026-09-21 本地保留策略确认:埋点队列及相关索引最多保留 7 天、总量上限 20 MiB。任一条件达到即按第 7.3 节清理,未上传的数据也适用;不影响项目、Agent 对话或创作成果。本版尚不上传,数据不会无限保留等待上传功能上线。 -2026-09-21 本地封存时间确认:从当前批次第一条事件进入开始,5 分钟到期后封存落盘;没有事件不生成空批次。该时间与后续每 15 分钟上传的周期独立,500 条及 1 MiB 的批量上限仍为草案建议。 +2026-09-21 本地封存时间确认:从当前批次第一条事件进入开始,5 分钟到期后封存落盘;没有事件不生成空批次。该时间与后续每 15 分钟上传的周期独立,本轮实施评审采用 500 条及 1 MiB 的提前封存上限,作为内部常量。 -2026-09-21 策划进度采集确认:不设固定采集周期;项目打开成功、相关策划状态成功持久化时触发,从 `.agent/design-agent/session.json` 提取白名单状态快照,内容无变化不重复记录;项目切换或关闭前尽力补采。5 分钟是封存落盘周期,后续 15 分钟是上传周期,均不是采集周期。不为策划埋点专门增加文件 revision 机制。 +2026-09-21 策划成果采集确认:审批通过后实际进入下一阶段,且该阶段变化成功持久化,视为一次 project_revision_created;不严格校验文档版本或文件差异,不为埋点新增文件 revision 机制。阶段内随意修改文件不逐次采集,打开、重开或关闭项目不补历史阶段事件。同策划会话同目标阶段幂等;不独立采集策划进度事件或审批、澄清状态字段。5 分钟是封存落盘周期,后续 15 分钟是上传周期,均不是采集周期。 用户已确定的后续要求:上传至主站数据库;客户端每 15 分钟上传一次;绑定真实用户与业务标识;成功后删除对应待上传副本;失败无弹窗、不阻塞正常进程;失败是否重试可选。本草案建议后续失败保留至下一个周期重试,不做立即重试;这是建议,不是已实现行为。 -本文是团队共享的埋点技术方案唯一维护入口,原始需求副本仅用于追溯。正式编码前,按仓库规范驱动工作流在 `docs/project-memory/plans/` 补齐里程碑规范及当前里程碑实施计划,并完成对应评审;本文的建议项不因迁入仓库而自动视为已确认。本次仅交付文档。 +本文是团队共享的埋点技术方案唯一维护入口,原始需求副本仅用于追溯。正式编码前,按仓库规范驱动工作流在 `docs/project-memory/plans/` 补齐里程碑规范及当前里程碑实施计划,并完成对应评审;本文的建议项不因迁入仓库而自动视为已确认。本次按技术负责人授权推进实施,里程碑仍按规范逐项评审和验收。 ## 2. 已核实的现有记录与复用边界 @@ -44,9 +44,10 @@ Date: 2026-09-21 | `.agent/conversations/project.jsonl` | Game Agent 正式对话历史,包含正文 | 复用正式受理、终态的业务入口;不复制正文到埋点 | | `.agent/runtime/direct-codex/turns/.jsonl` | 回合工具审计,条目有上限,写入可失败 | 参考执行事实;不能把其条数作为完整产品事件数量 | | `.agent/agent.db` | 逐行 JSON 本地索引与审计,非 SQLite | 保持原用途,不作为待上传队列 | -| `.agent/design-agent/session.json` | 策划会话、对话、工具结果、阶段、审批、当前回合与恢复状态 | 状态保存成功后触发,提取白名单进度快照;不上传完整会话文件 | +| `.agent/design-agent/session.json` | 策划会话、对话、工具结果、阶段、审批、当前回合与恢复状态 | 审批通过且实际推进阶段成功持久化后记录成果事件;不上传完整会话文件 | | `design_artifacts/` | 正式策划成果 | 保持原文件保存行为,本版不为统计新增 revision;文件内容不进入事件 | | 应用级 `direct-executions/`、`direct-delivery/` | 执行控制与交付状态 | 关联正式执行生命周期;不额外复制一套业务状态机 | +| `.agent/analytics-goal.json` | 新增的项目目标采集资格:格式版本、项目 ID、已消费状态 | 仅后台初始化和消费;跨批次清理保留,不包含用户、对话或执行内容,不参与业务放行 | 旧 Planning V1/V2 与 Fast GDD 不作为新埋点接线目标。主站已有 HTTP tracking 也不等于本方案事件已经被接收或保存。 @@ -85,7 +86,7 @@ Date: 2026-09-21 | --- | --- | --- | | schema_version | integer | 固定 1;本文新增,供本地读取与后续服务端识别版本 | | event_id | string | 客户端 UUID v4,单条事件唯一;重试、复制批次与恢复重放沿用原值 | -| event_name | string | 仅允许第 6 节本版启用的 13 个英文事件名(原方案 12 类加 design_progress_snapshot);session_timeout 不在本版写入白名单中 | +| event_name | string | 仅允许第 6 节本版启用的 12 个英文事件名;session_timeout 不在本版写入白名单中 | | event_time | string | 业务事实发生时捕获的 UTC 时间;不能以重启检测时间冒充旧会话的退出时间 | | user_id | string/null | 由已确认的平台登录会话取得;匿名明确 null,不从邮箱、设备或项目所有者猜测 | | editor_session_id | string | 埋点专用 UUID;不复用 Runtime sessionId、threadId 或 clientTurnId | @@ -119,12 +120,18 @@ source 新增 design_agent 以表达现役策划实现;不使用旧 supervisor 同实例多个可交互编辑器窗口的前台区间取并集,不累加重叠时长。以宿主查询的窗口总体状态为准,聚合窗口切换;不同应用实例独立记会话。后台 Runner 自己不产生 editor_session_start。 +现役生产界面由 WorkspaceLauncher 进入项目工作区,内嵌 App 的 hydration 不是第二次打开。打开采集以 Launcher 成功采纳的显式导航操作为准。当前没有已验证的文件关联或上次工作区自动恢复入口,不为填满枚举而伪造 app_restore/project_association。当前原生回调可观察普通焦点和最小化;尚未接入独立锁屏/休眠系统通知,可能存在前台时长误差,不宣称完整覆盖系统休眠。 + +身份变化、真实工作区卸载和 WebView 开始重载时清除当前项目归属;卸载后迟到的异步导航不能记录为成功打开。已经成功的旧身份操作允许延迟记入历史,但不能恢复当前项目。生命周期状态在只含内存操作与非阻塞投递的短临界区串行更新,身份通知不能因竞争而永久丢失;启动身份快照与服务发布同步,等待认证状态在后台进行,不阻塞 GUI。 + +异常会话以每个 analytics 实例持有的独立文件锁证明存活;写 active 标记前先取得锁,恢复旧会话时取得对应锁并持有到修改完成。锁缺失、格式未知或无法判断所有权时保留原状,不用 Runner 共享参与锁代替。 + ### 5.2 项目与创作目标一一对应 - 用户已确认:一个项目就是一个目标。同项目内修改需求、改变方向、普通追加、澄清、审批和用户重试都不产生新目标;只有不同项目才对应不同目标。 - 建议采用最简单的字段映射:creative_task_id 直接复用 manifest 的 project_id 值,保留两个字段的契约名称但不维护第二份随机 ID 或映射表。该等值映射是本文实现方案,不表示 clientTurnId、agent_run_id 也可复用项目 ID。 - 项目改名、移动目录、重开、客户端重启、换账号以及从 Design Agent 转到 Game Agent 均不重建目标 ID。另存为新项目时应使用业务实际生成的新 project_id;复制目录但仍保留相同 project_id 的情况按同一项目身份处理,埋点不自行修正项目身份。 -- creative_task_submit 的拟定采集口径保持每个目标首次受理创作请求时一次;仅创建或打开项目不算提交。之后的执行用多个 run 表达,不把消息条数算成目标数。此事件频次属于草案口径,需与第 11 节一并评审。 +- creative_task_submit 每个目标只记录一次本地成功采集到的真实受理;仅创建或打开项目不算提交。之后的执行用多个 run 表达,不把消息条数算成目标数。首次是本地观测口径,不保证绝对首次操作:较早受理的埋点丢失时,后续真实受理可以成为首条记录,仍使用后者自己的发生时间与身份,不补原历史。 - 首次提交事实随项目已有受理记录或最小标记持久保存,不能只存在 React 状态或会过期的上传索引中。上传后清理批次不清除目标已提交事实,避免重开项目重复首次提交。 - 现有项目的目标 ID 可由真实 project_id 直接确定,但不补发历史事件。无法证明是首次创作请求时,不将首次启用埋点或第一次遇见旧项目冒充首次提交;后续 run、revision 等仍可记录并关联项目目标。 - 不新增“新目标/继续目标”选择器,不使用模型、关键词、消息间隔或最近任务推断目标归属。所有任务相关事件校验 creative_task_id == project_id。 @@ -133,10 +140,14 @@ source 新增 design_agent 以表达现役策划实现;不使用旧 supervisor - 一次用户指令或明确继续动作被 Runtime 受理后生成 agent_run_id,执行至正常返回、最终失败、取消或中断。一次任务可以包含多个 run。 - 同一执行内部的 Provider 重试不创建新 run;相同受理请求重放复用原 run ID。 -- 用户主动重试形成新的 run,沿用任务 ID。retry_index 定义为该任务的用户主动重试序号:初始执行为 0,主动重试递增;普通澄清/审批继续不递增。它不表示 Provider 的请求重试次数。 +- 用户主动重试形成新的 run,沿用任务 ID。retry_index 为该项目启用采集后、后台成功处理的累计显式用户重试次数:初始观测为 0,主动重试递增;普通提交、澄清/审批不递增。它不表示 Provider 的请求重试次数,也不能证明启用前的完整历史;受理候选丢失可能少计。现役 Direct 没有明确用户重试入口,普通再次发送仍为 user_submit,不按文本或上次失败猜测 user_retry。 - 正常等待澄清或审批结束本次 run,以 completed + end_reason 区分;用户答复后新建 run。completed 表示本次执行正常收束,不代表整个任务完成或用户满意。 - 用户取消、崩溃、结果不确定不伪造 completed 或 failed。原方案未覆盖这些 run 终态,本版不新增对应终态事件,明确它们不进入成功/失败分母。 - duration_ms 用单调时钟测量本次执行,含内部等待与自动重试,不含等用户答复的间隔;跨进程恢复无法可靠累加时为 null。 +- run UUID、终态事件 UUID、原身份与来源随已有业务受理记录保存,不新增业务线程的埋点写盘。旧执行无元数据时恢复不补事件。新请求重放不再登记受理;同一次自动恢复沿用原 run。 +- 后台项目记录仅维护重试计数及每类 Agent 一个当前 run 槽位,原子分配序号并幂等消费终态;不保存全部历史。新受理可覆盖同类旧槽位,迟到旧终态因而可能漏记;缺失、损坏或锁竞争均静默丢弃,不猜序号。消费成功但事件落盘前退出可能丢数,不重置终态资格。 +- 跨进程恢复的终态继续使用受理时的用户、平台与 run ID,editor_session_id、client_version 使用实际观察终态的当前 GUI 实例,duration_ms=null;不使用恢复时的新账号替换原身份。没有 GUI 埋点 writer 的执行不制造编辑器会话。 +- Direct 以宿主账本和整体执行结果共同判定:Interrupted 或取消/结果不确定不记终态;Exhausted 记 runtime_failed;明确错误记 failed;正常 Completed 或无交付合同的普通对话正常返回记 completed。需要交付合同但仍未完成时不猜 completed。判定在执行 guard 释放前完成,不改变 UI 返回结果。 ### 5.4 下游因果关联 @@ -144,7 +155,9 @@ project_revision_created、preview_ready、project_save 已知所属项目,因 执行上下文冻结 project_id、creative_task_id、agent_run_id、用户与平台身份;后续不能从当前选中项目重新取值。run 的 output_change_detected 必须根据本次执行可归因的有效修改判定,不能只比较全局 revision 前后值,因为其他窗口也可能改动项目。 -## 6. 事件合同(原方案启用 12 类,新增 1 类策划进度快照) +Direct 的自动登录刷新重试仍属同一个 run。原生单次调用结束只生成终态候选,外层自动重试链结束后才确认最后一次调用的候选;中间鉴权错误不提前消费 run 终态。每次原生调用使用独立的临时尝试标识,确认必须匹配最后一次尝试,避免取消、不确定结果或队列丢弃后误用旧失败。账号代次变化则丢弃候选;同账号凭据续期不改变代次。候选仅有界保存在内存,不写历史、不增加网络或业务等待;窗口关闭、崩溃、候选丢失或最后一次前置拒绝可漏记,不回退确认先前尝试。前端只确认调用链结束,不提供成功或失败结论。恢复或跨原生调用无法证明连续耗时时,duration_ms 为 null。 + +## 6. 事件合同(原方案启用 12 类) 本节 properties 中“可选”字段在不可得时省略;未标可选的字段必须存在。公共 status、ID 与 source 的赋值同时受第 4、5 节约束。 @@ -208,10 +221,12 @@ project_revision_created、preview_ready、project_save 已知所属项目,因 ### 6.8 creative_task_submit -- 触发:项目目标的首次创作请求通过基本校验并被业务受理,且首次提交事实可确认;不是创建项目、点击按钮、未提交草稿或请求重放。 +- 触发:本版实际新建项目的创作请求通过基本校验并被业务受理,且后台成功消费尚未提交的项目资格;不是创建项目、点击按钮、未提交草稿或请求重放。每项目最多采集一次。 +- 项目资格只在真实创建成功后初始化,记录格式版本、project_id 与已提交状态,随项目保留,不进入 7 天批次清理。后台互斥消费后才投递事件;此前候选丢弃而资格尚未消费时,后续真实受理可记录。资格已消费但事件未落盘时允许漏记,不反向重建资格。资格缺失、损坏或身份不符均为未知。 +- 并发候选以成功持久消费资格的顺序决定保留哪次受理,不保证业务最早请求或最早 event_time;最终事件可能为零条。项目标记属于持久的观测状态,不保存用户身份、正文或路径,也不作为业务执行门禁。 - 公共字段:status=success;project_id、creative_task_id 必填;source=direct / design_agent;run/turn ID 均 null。 - properties:空对象。公共字段不重复,不增加 Prompt、长度、附件或任务分类。 -- 去重事实键:creative_task_id + event_name;同项目的澄清、审批、追加和重试只创建后续 run。项目级首次提交标记跨应用会话及批次清理保留。 +- 去重事实键:creative_task_id + event_name;已成功消费资格后,同项目澄清、审批、追加和重试不再发本事件。项目级首次提交标记跨应用会话及批次清理保留。 - creative_task_id 必须等于 project_id;同一项目先策划再开发也不再记第二次目标首次提交。 ### 6.9 agent_run_completed @@ -220,7 +235,7 @@ project_revision_created、preview_ready、project_save 已知所属项目,因 - 公共字段:status=success;project_id、creative_task_id、agent_run_id 必填;agent_turn_id 可空;source=direct / design_agent。 - properties:agent_type=game_agent / design_agent;run_source=user_submit / user_continue / clarification / approval / user_retry;duration_ms(integer/null);retry_index(integer);output_change_detected(boolean/null);revision_id 可选;end_reason=finished / waiting_for_user / waiting_for_approval。 - output_change_detected=true 必须有本次归属的有效变更证据;false 必须完成检测且确认无变化;无可靠证据填 null。revision_id 仅填本次归属的最新已提交 revision。 -- 策划阶段快照不能作为文件内容变化证据;Design 执行若没有独立可靠的成果变更检测,output_change_detected=null 并省略 revision_id,不因阶段推进而填 true,也不因阶段没变而填 false。 +- 对 Design,本次受理操作中已成功持久化的阶段推进即可作为变化依据,output_change_detected=true 并关联该阶段 revision,不额外要求文档版本或文件差异检测。没有阶段推进时仍可能改了文件,若没有其他变化依据则填 null、省略 revision_id,不能因阶段没变而填 false。 - 去重事实键:agent_run_id + terminal;同一个 run 最多一个正常终态,不因 GUI 与 Rust 双写而重复。 ### 6.10 agent_run_failed @@ -234,23 +249,32 @@ project_revision_created、preview_ready、project_save 已知所属项目,因 ### 6.11 project_revision_created -- 触发:代码、资源、UI 等成果确实发生有效变化,且现有业务修改与正式 revision 都成功提交。存储损坏或不确定提交不记成功。 +- 触发:Game Agent、代码、资源、UI 等已有 revision 路径要求成果确实发生有效变化,且现有业务修改与正式 revision 都成功提交;策划阶段推进采用下述独立口径。存储损坏或不确定提交不记成功。 - 公共字段:status=success;project_id 必填;creative_task_id 与 project_id 相同;run/turn ID 为 null;source 使用实际修改模块。 -- properties:revision_id(将项目内真实 revision 转为字符串,不使用 event_id);revision_source=agent / asset_canvas / resource_editor / ui_editor / manual_edit / system_projection;change_kind=code / asset / ui / design_document / mixed;files_changed_count 可选。 +- properties:revision_id(已有正式 revision 转为字符串;策划阶段推进使用下述稳定事实标识,不使用 event_id);revision_source=agent / asset_canvas / resource_editor / ui_editor / manual_edit / system_projection;change_kind=code / asset / ui / design_document / mixed;files_changed_count 可选。 - 只更新聊天历史、访问时间或运行状态不属于有效变化。system_projection 仅在投影真实业务成果时允许,纯修复缓存/同步元数据不进入有效变化率。 - 当前 Direct 的 outputs_changed 与 manifest_requires_sync 要区分;不能给 revision 递增函数加一个无条件事件后就宣称满足语义。 -- Design Agent 本版改用第 6.14 节的进度快照,不为了埋点给策划写文件、补丁或删除补建 revision。不得把阶段名、turn_index、session.updated_at 或审批结果填成 revision_id;仅当已有业务实际产生正式 revision 时,才可按本事件原条件记录。 +- Direct 的宿主 file.write 与正式补丁事务使用持锁目标内容比较和本次已提交项目 revision,成功且内容确实变化时记 source=direct、revision_source=agent。运行期间的全局 fingerprint 和写许可本身不证明本 run 改了文件;末尾投影登记不再重复统计工具已经提交的成果,也不将纯登记修复计为新成果。未经过可观察宿主提交的外部/命令行写入不推断事件。 +- Direct run 的 output_change_detected=true 与 revision_id 来自本 run 已确认的宿主成果提交;保存当前内存中最后一个真实成果 revision,不为埋点新增业务文件版本或同步写盘。没有观察到成果、恢复后缺少该证据时为 null,不能推断 false。后续 run 失败不撤销已提交成果事件。 +- 宿主文件通道仅对已识别成果类型采集:策划成果目录、UI 目录以及支持的代码/媒体扩展名;私有控制面、隐藏记录、构建缓存和类型未知的文件不据此统计。该口径不承诺观察全部磁盘文件;恢复后的未知成果证据不补填。 +- Design Agent:仅审批通过后实际进入下一阶段且成功持久化时记录;不严格检查文档版本或文件差异,不增加业务文件 revision。revision_id=design::,使用真实策划会话 ID 和本次实际进入的目标阶段;source=design_agent,revision_source=agent,change_kind=design_document,files_changed_count 省略。该标识只表示阶段推进事实,不代表文件版本。 +- 策划同会话同目标阶段只记一次;同阶段内文件写入、普通对话、工具执行、提交审批、审批拒绝、等待澄清、没有实际阶段变化的批准,以及持久化失败均不产生该成果事件。项目重开、会话恢复或读取现有阶段不补历史。 +- 策划阶段事件的 event_time 取本次阶段变更成功持久化时间,用户和平台取审批操作开始时捕获的身份,不能归入异步完成时的新账号。 - 去重事实键:project_id + revision_id + event_name。一个原子 revision 只记一次;多来源合并提交按真实提交范围填写,禁止任选最后一个来源。 - 外部编辑器的修改仅在宿主已有变化确认与 revision 提交时覆盖;本版不新建全盘监听,不宣称能看到所有磁盘改动。 +- 采集覆盖边界:第一阶段记录基础创作链路中的项目变化,不要求逐项覆盖所有资源操作。资源上传、删除、重命名、派生、图片正规化、音频/画布生成、版本资源替换与批量导入,不因已有业务 revision 就自动成为本期必须接入的埋点入口。撤销此前为这些入口新增的全面接线计划,不新增资源账本采集字段。已接入的 Direct 文件/补丁、人工编辑与 UI 成果按上述真实变化规则记录;统计结果仅表示已观测变化,不能宣称是全量资源操作或项目全部修订次数。 ### 6.12 preview_ready - 触发:宿主对本次预览实例的实际入口执行访问检查成功,且确认检查期间项目版本仍匹配;Web 以真实入口成功响应且内容存在为最低可访问判据,其他引擎需各自真实 ready 信号。 - 公共字段:status=success;project_id 必填;creative_task_id 与 project_id 相同;run/turn ID 为 null;source 为实际发起模块。 - properties:preview_source=user / agent / auto_restore;preview_version 为该实例实际服务的项目 revision 字符串;ready_duration_ms 可选。 -- 当前代码只启动监听、写 Running、返回 URL,不能直接产生本事件。探测失败或版本漂移不产生 ready;不记录本地 URL、端口或绝对路径。 +- 只启动监听、写 Running、返回 URL 不能直接产生本事件。探测失败或版本漂移不产生 ready;不记录本地 URL、端口或绝对路径。 - 去重事实键:项目 + 预览实例 ID + preview_version + event_name;轮询与重复回调不重复。同版本真正重开预览可产生新事件,漏斗按任务去重。 - ready 只证明可访问,不证明 JS 无异常、游戏通关或用户满意。仅策划文档项目不强行产生游戏预览事件。 +- 正式入口覆盖 GUI 用户启动的持续 Web 预览(editor/user),以及 Direct 宿主浏览器验证的临时 Web 预览(direct/agent)。Direct 复用原 run 捕获身份;无新实例的缓存验证不补事件。外部任意 URL、环境自检项目和旧开发/CLI 入口不据 URL 推断本事件;当前无正式 auto_restore 新建实例入口,不伪造来源。 +- 每次实例使用独立内存 ID 和存活标记;GUI 停止/替换、Direct 临时验证结束或取消时失效。异步有界 loopback GET 探测不得延迟或改变预览/验证返回;仅对宿主端口的根入口请求,禁代理与重定向。失败不重试、不弹窗;只记录第一次可信 ready,不等待 JS 或整个浏览器验证成功。 +- 探测前后确认真实入口文件非空,避免空 HTML 被辅助脚本注入后误判;前后项目 revision 和实例须与启动捕获值一致。探测只读已有版本,不推进版本、不持有跨网络请求的项目锁;读取未知则跳过。原始响应、路径、端口不进入事件。 ### 6.13 project_save @@ -259,35 +283,12 @@ project_revision_created、preview_ready、project_save 已知所属项目,因 - properties:save_source=manual / auto / checkpoint;revision_id 可选,取本次保存结果对应的真实 revision。 - Agent 会话检查点、审计记录、埋点写入、配置保存、云快照同步不算项目保存。独立素材导出也不自动等于项目保存。 - 无变化的自动保存不记;用户显式保存成功可记,但不能同时伪造 revision。失败或部分写入不记成功。 +- GUI 人工文件写入在业务成功后记 manual 保存;同内容可以保存,但仅真实内容变化才记 manual_edit 成果。删除成功产生变化事件,不冒充一次保存。仅已识别成果文件参与统计。 +- GUI 手动项目 checkpoint 完整成功后记 source=manual、save_source=checkpoint,复用真实 checkpoint_id 作为操作身份,revision 不确定时省略;不同于 Agent 会话 checkpoint。其他工具调用该底层函数不自动归为人工。 +- UI 编辑器普通保存与保存并生成是两个逻辑操作。State 的 Saved 结果可记 ui_editor 成果,Unchanged/Conflict 不记成果;普通手动保存 Saved/Unchanged 均可记一次保存,自动保存仅 Saved 记。保存并生成必须等代码生成也成功后才记一次保存,不能在中间 State 成功时提前记;其保存事件省略没有整体提交依据的 revision_id。原生 State 成功事件即使后续生成失败也保留。 +- UI 逻辑保存起点捕获宿主身份和操作 UUID,收尾异步非等待回传;原生校验本 GUI 会话、字段和真实项目再投递。身份捕获、桥接或采集失败不改变业务结果。不增加 UI 业务状态机或保存专用持久化。 - 去重事实键:项目 + 保存操作 ID + event_name。自动保存不表示用户认可;看板必须按 save_source 区分。 -### 6.14 design_progress_snapshot - -- 用途:记录采集时实际观察到的策划进度,不是审批操作流水,也不证明文件内容已改变。 -- 唯一事实来源:对应项目 `.agent/design-agent/session.json` 已成功持久化的状态。不读取 `.debug`,不解析自然语言,不上传整个 session 文件。 -- 触发:项目打开成功后采初始快照;阶段推进、提交审批、审批通过/拒绝、进入或结束等待澄清的状态成功保存后触发;切换或关闭项目时尽力补采。没有固定采集定时器,不等 5 分钟才读取。 -- 普通模型输出、工具调用、对话保存、重试计数变化,若未改变白名单进度状态,不产生本事件。没有策划会话的项目跳过,不创建伪会话或默认初始阶段。 -- 公共字段:status=null;source=design_agent;project_id 必填,校验会话 projectId 与项目 manifest 一致;creative_task_id=project_id;agent_run_id 与 agent_turn_id 为 null。editor_session_id 仍是编辑器会话,不使用策划 sessionId 替代。 -- event_time 是成功读取该快照的观察时间,不回填为阶段真实转换时间。用户归属按触发上下文冻结:业务推进使用该次执行的用户,打开/关闭观察使用该操作的用户;不把快照中的历史阶段声称为当前观察者完成。 - -properties 白名单: - -| 字段 | 类型 | 来源与语义 | -| --- | --- | --- | -| design_session_id | string | 原 sessionId,用于区分同项目重置后的策划会话;不改变项目目标 ID | -| current_phase | string | 原 currentPhase,仅 concept / top_design / architecture / systems / tdd / consultant | -| approved_phases | string[] | 原 approvedPhases;按固定阶段顺序规范化,无重复;不从 current_phase 推算 | -| pending_approval | object/null | 原 pendingApproval 提取 request_id、phase;没有待审批时为 null | -| pending_clarification | object/null | 原 pendingClarification 仅提取 request_id;不采问题正文和选项;没有时为 null | -| capture_reason | string | project_open / state_persisted / project_leave,表示观察触发方式,不冒充具体审批动作 | - -- 不采 history、messages、commands、工具参数/结果、Prompt、错误正文、产物正文或路径。类型异常、未知阶段、项目身份不匹配或读取失败时静默跳过并计数,不填默认值。 -- 比较范围为同平台、同用户、同编辑器会话、同项目与同 design_session_id 下最近一次已接收快照。比较 current_phase、approved_phases、pending_approval、pending_clarification;排除 event_time、capture_reason 及文件更新时间,避免普通会话写入反复触发。 -- 第一次观察记录一次;同观察范围内状态相同则不重复。新编辑器会话或新用户需要自己的初始观察。只与相邻快照比较,不做永久内容去重:A→B→A 必须可记录三次,新的审批 request_id 也构成变化。 -- 快照事件使用独立 event_id;重复回调的相邻相同状态被抑制,已排队事件的后续落盘/上传重试沿用原 ID。队列拒绝时不更新“已接收快照”,以便后续触发仍有机会采集。 -- 读取和投递不阻塞正常业务。采集必须绑定触发时的项目路径和身份,不能在执行异步读取时改用当前选中项目。异步读取时状态可能已经推进,事件诚实记录实际读到的最新状态,不伪造漏掉的中间审批/澄清动作;本方案不承诺完整操作审计。 -- 关闭项目不会删除已进入应用级队列的快照;整个客户端退出则尽力提前封存,强退仍适用未落盘丢数限制。 - ## 7. 本地存储、恢复与去重 ### 7.1 位置与文件格式 @@ -305,7 +306,7 @@ analytics/ batch_id 为随机 UUID;每个实例由单个后台写入器负责。批次在内存中组装,在同文件系统的临时批次目录写 meta.json 与 events.jsonl,写入完成后原子发布整个目录。未来上传只处理已发布批次,不读取在写临时目录。账号或目标平台变化时切新批次。 -已确认时间条件:从当前批次第一条事件进入开始计时,5 分钟到期后由后台写入器封存落盘;后续事件进入新批次,没有事件时不产生空批次。仍建议批次达到 500 条或 1 MiB 序列化事件大小时提前封存,两个批量上限尚待评审。正常退出尽力封存,不新增阻塞退出的等待。 +已确认时间条件:从当前批次第一条事件进入开始计时,5 分钟到期后由后台写入器封存落盘;后续事件进入新批次,没有事件时不产生空批次。批次达到 500 条或 1 MiB 序列化事件大小时提前封存;该内部上限已在本轮实施评审中采用。单事件超过批次字节上限时静默拒绝,不生成超限批次。正常退出尽力封存,不新增阻塞退出的等待。 这些是本地写入参数,与未来 15 分钟上传周期独立。封存前原始事件只存在有界内存,强杀或断电可能损失最近约 5 分钟尚未封存的数据;后台调度或写盘延迟可能扩大窗口,不承诺严格的最大丢数时长。实现时作为内部常量,不增加用户设置界面。 @@ -375,12 +376,12 @@ session.json 是本地恢复元数据,不是待上传事件;事件文件不 ### 8.3 可用指标的限制 - 匿名事件不纳入按用户的留存;稳定用户也要等主站接收链路验证后才能出正式留存。 -- 创作目标数就是项目目标数,不是消息数或执行次数;目标首次提交漏斗按有 creative_task_submit 的项目去重。旧项目缺失首次提交事件时不回填,应明确其不在该漏斗 cohort 中。 +- 创作目标数就是项目目标数,不是消息数或执行次数;目标提交漏斗按有 creative_task_submit 的项目去重。旧项目缺失首次提交事件时不回填,应明确其不在该漏斗 cohort 中。新项目首条记录表示首次成功采集的真实受理,不能声称其时间和用户一定等于绝对首次操作;此前丢失的候选可能来自更早时间或其他用户。 - 同目标可能跨天、跨会话、跨账号并经历许多 run。回访创作可由后续 run、revision、project_open 观察,不能只用首次 creative_task_submit 判断用户是否继续创作;每会话目标首次提交数不再代表每会话交互次数。 - 人工修改、预览和保存归入相同项目目标,但不能据此宣传为 Agent 导致的成果;run 的 output_change_detected 仍需要本次执行的真实变化证据。 - Game Agent 与 Design Agent 的预览适用性不同;不能把所有策划任务算入“游戏预览失败”的分母。 -- 策划进度按 design_progress_snapshot 的阶段、已批准阶段与等待状态统计,不依赖文件 revision。打开旧项目得到的是一次当前观察,不能当成这次用户新完成阶段;不由观察时间计算精确阶段耗时或审批操作次数。 -- 快照是进度观测,原方案的 revision 有效变化率不能把快照数加进分子;策划进度与 Game Agent 成果变化分别统计。 +- 策划成果按成功持久化的阶段推进记录为 project_revision_created,可作为策划成果次数统计;它不证明文档内容改变,也不等于某次 run 产生文件变化。按 source=design_agent 与其他成果来源区分解释,不能混称代码或文件有效变化率。 +- 没有独立策划进度、审批或澄清状态字段;阶段推进记录不能推导完整审批次数或精确阶段耗时,重开旧项目不补历史成果。 - 自动保存、checkpoint 与手动保存分开解释;focus 仅为前台时长。 - 只采集创建成功事件无法计算创建成功率;不采 task_type 就不能按任务类型分组;不能拿 agent_type 冒充 task_type。 - 取消、崩溃与未闭合 run 未纳入失败事件,本版完成率是已知终态样本的完成率,不是所有提交的完成率。 @@ -410,23 +411,23 @@ session.json 是本地恢复元数据,不是待上传事件;事件文件不 "run_source": "user_submit", "duration_ms": 32000, "retry_index": 0, - "output_change_detected": false, + "output_change_detected": null, "end_reason": "waiting_for_user" } } ``` -该例表示:本次策划执行正常停在等待用户澄清,已确认没有成果变化;不表示整个任务完成,不记录 revision_id。 +该例表示:本次策划执行正常停在等待用户澄清,没有已记录的阶段推进且文件变化未知;不表示整个任务完成,不记录 revision_id。 ## 10. 实施拆分与验收依据 -本节仅列待评审的里程碑范围,不授权编码,不创建上传实现。 +技术负责人已授权按计划落地;以下为实施里程碑边界。合同与本地队列、会话窗口与项目接入、策划阶段推进成果事件、首次提交、两类 Agent run、Direct 宿主文件/补丁成果、GUI 人工编辑、checkpoint 与 UI 保存,以及正式 Web 预览 ready 均已定向验收,并完成下述同一宿主组件链路的关联验证。全部资源操作的逐项接入不是本期完成条件。 | 里程碑 | 范围 | 退出判据 | | --- | --- | --- | | A 数据合同与本地队列 | 强类型事件、捕获身份、JSONL 批次、有界异步写入与恢复、静默失败 | 字段校验、JSONL 读取、跨账号、不阻塞、损坏隔离、容量与幂等测试通过 | | B 会话、窗口与项目 | start/end/focus/create/open,以及下次启动的本地 incomplete 标记 | 多窗口、重载、最小化、正常退出、旧实例存活识别、异常未闭合、项目重复打开测试与桌面 smoke | -| C 两类 Agent 与成果链路 | submit/run、策划进度快照、已有真实 revision、preview/save | 成功失败与等待、持久化触发快照与去重、重试关联、无变化、版本漂移、真实访问与保存边界全部可核对;不新建策划文件 revision 机制 | +| C 两类 Agent 与成果链路 | submit/run、策划阶段推进成果、已有真实 revision、preview/save | 成功失败与等待、阶段实际推进且持久化成功与幂等、重试关联、无变化、版本漂移、真实访问与保存边界全部可核对;不新建策划文件 revision 机制 | | 后续独立阶段 | 上传主站与查询 | 另立规范;不在本版实施范围 | 实施前为选定里程碑生成单独实现计划,前一个里程碑评审/验收后再推进下一项。 @@ -437,10 +438,10 @@ session.json 是本地恢复元数据,不是待上传事件;事件文件不 | --- | --- | | 正常完整创作 | 读取本地 JSONL 批次得到原方案完整链路;事件 ID 各自唯一,关联 ID 同语义一致 | | 策划澄清/审批/继续 | 同一 task、多次 run;等待不误报失败,不重复 task_submit | -| 策划状态保存与采集 | 保存成功后读取白名单;保存失败不伪造新状态;普通对话保存而阶段状态不变时不重复 | -| 策划项目 5 分钟内打开又关闭 | 打开时采初始快照,期间相关持久化触发采集;切换/关闭尽力补采,队列不随项目关闭消失 | -| 策划快照 A→B→A 与连续相同状态 | 前者三个观察都可记录;后者去重;采集时间仅为观察时间,不冒充审批发生时间 | -| 策划文件缺失、损坏、身份不符或快速切项目 | 静默跳过,不填伪默认阶段;异步结果不绑定到当前其他项目或其他用户 | +| 策划审批通过并推进阶段 | 目标阶段成功持久化后产生一条 revision;ID 为 design::,来源与 change_kind 按第 6.11 节固定 | +| 策划项目 5 分钟内打开又关闭 | 不补历史阶段成果;期间新产生的阶段推进进入应用队列,不随项目关闭消失 | +| 策划阶段重复回调与阶段内修改 | 同策划会话同目标阶段幂等;阶段内任意文件修改、审批拒绝、无阶段变化均不单独生成成果事件 | +| 策划阶段持久化失败或跨账号完成 | 保存失败不记成果;异步结果保留操作开始时项目与用户身份,不绑定到当前其他项目或其他用户 | | 项目重开、改名、移动、切账号及策划转开发 | project_id 不变时 creative_task_id 恒定且等于 project_id;首次提交不因批次删除再产生 | | 不同项目与旧项目首次观测 | 不同 project_id 对应不同目标;旧项目不补造历史首次提交,后续事件仍可关联目标 | | 自动请求重试/用户重试 | 前者原 run,后者新 run;retry_index 口径一致 | @@ -458,7 +459,7 @@ session.json 是本地恢复元数据,不是待上传事件;事件文件不 | 批次发布中断与重启 | 保留完整已发布事件批次,未发布临时目录可清理;不重新生成已发布事件 ID | | JSONL 截断、坏行或身份冲突 | 损坏批次隔离并计数,不改写事实、不清空其他批次、不阻塞业务 | | 数据最小化 | 正式事件和元数据符合白名单,日志不重复完整事件;无 Prompt、凭据或工具正文等禁止字段 | -| 本阶段运行 | 不因埋点发起 HTTP,不创建 15 分钟上传定时器或周期会话检查点,不补发 timeout,不删除“假确认”数据;本地批次封存定时不受影响 | +| 本阶段运行 | 不发起埋点上传或外部 HTTP;预览仅允许第 6.12 节的宿主 loopback 可访问性检查。不创建 15 分钟上传定时器或周期会话检查点,不补发 timeout,不删除“假确认”数据;本地批次封存定时不受影响 | 主要代码核查入口(相对仓库根目录): @@ -476,23 +477,56 @@ session.json 是本地恢复元数据,不是待上传事件;事件文件不 | 项目 | 本草案建议 | 未确认时的限制 | | --- | --- | --- | | 目标边界(已确认) | 用户已确认一个项目就是一个目标;方案据此令 creative_task_id=project_id | 不再需要消息意图分类或新增目标选择交互 | -| creative_task_submit 次数(草案建议) | 每个项目目标首次受理创作请求时一次,之后通过 run 表达继续执行 | 目标数不代表消息数;缺历史提交的旧项目不得伪造首次提交 | +| creative_task_submit 次数(实施采用) | 新建项目每个目标首次成功采集的真实受理一次,之后通过 run 表达继续执行 | 属于本地观测口径;不能证明绝对首次时间或用户,旧项目不得补历史提交 | | 异常会话处理(已确认) | 下次启动确认旧实例已退出且未正常结束,标记本地 incomplete;本版不采集 session_timeout | 无周期检查点、无超时阈值、无补造退出/前台时长;未再次启动则保持未闭合 | | 等待用户(已确认) | completed + end_reason,后续继续为新 run | run 完成不是任务完成 | | 取消与崩溃 run(已确认) | 本版不映射到失败事件 | 成功率仅覆盖已知正常/失败终态 | | is_first_open | 本安装当前账号可观察范围,未知为 null | 不能声称全平台首次打开 | | output_change_detected / duration_ms | 证据不足允许 null | 原方案未明确可空性,不能用 false/0 伪造确定值 | -| 策划进度(已确认) | 从持久化 session 提取白名单快照,打开/相关状态保存成功后触发,切换或关闭尽力补采,无变化去重 | 无定期采集;5 分钟仅用于封存;不能冒充完整操作流水或文件变更 | -| revision | Game Agent 与其他已有正式 revision 路径按真实变化采集;策划进度用快照,不为埋点新建策划 revision | 不把阶段、审批或快照当作文件 revision;剔除纯元数据同步 | +| 策划阶段成果(已确认) | 审批通过并实际进入下一阶段,成功持久化后复用 project_revision_created;同会话同目标阶段幂等 | 不采独立进度与审批字段;不严格校验文档版本/差异;阶段内修改不逐次采集,重开不补历史 | +| revision | Game Agent 与其他已有正式 revision 路径按真实变化采集;策划使用 design:: 阶段事实标识 | 策划标识不是文件版本,不据此伪造 run 文件变化;其他来源剔除纯元数据同步 | | 本地保留(已确认) | 最多 7 天或总量 20 MiB,任一条件达到即清理;超量先清最旧批次 | 未上传数据也会清理,本版不保证保留至上传功能上线;不删除项目及 Agent 业务数据 | | 本地封存时间(已确认) | 当前批次首条事件进入后 5 分钟封存;无事件不生成空批次 | 与 15 分钟上传独立;强退可能丢失尚未落盘的一批事件 | -| 本地批量上限(草案建议) | 达到 500 条或 1 MiB 序列化事件大小提前封存 | 两个上限尚待评审,不影响已确认的 5 分钟时间条件 | +| 本地批量上限(实施采用) | 达到 500 条或 1 MiB 序列化事件大小提前封存;超大单事件静默拒绝 | 内部常量,与 5 分钟条件并行;不新增用户设置 | | 字段补充 | schema_version、focus_interval_id、run end_reason;incomplete 仅是本地生命周期状态 | 需与后续接收端按同版合同实现,不任意扩展 properties,不将本地状态冒充产品事件 | | 本地格式(已确认) | 本阶段不加密,使用明文 JSONL 批次 | 无密钥前置要求;原字段白名单与数据最小化要求不变 | | 未来确认删除 | 整批确认、整批删除,失败重发原事件批次 | 服务端仍需逐事件去重;暂不增加逐事件部分重写机制 | -## 12. 本次方案验证状态 +## 12. 实施与验证状态 已做:原产品方案逐项对照;核对现役 Direct/Design 持久化、项目创建、revision、预览、UI 保存与 checkpoint 的源码入口。本文新增合同和参数均以草案标识,不作为已经上线的事实。 -本次将需求来源和当前技术方案纳入仓库并更新团队文档入口,不修改业务代码、数据库或网络行为;未运行客户端功能测试、真实窗口 smoke、主站 API 或数据库验证。文档编码、示例 JSON 与仓库文档检查结果随交付说明提供。 +合同与本地队列、会话窗口与项目接入、策划阶段推进成果事件、首次提交、两类 Agent run、Direct 宿主文件/补丁成果、GUI 人工编辑、checkpoint 与 UI 保存,以及正式 Web 预览 ready 已完成实现、独立审查和定向验收。最终按原始需求和已确认口径核对全部 12 类事件入口,并补齐同一 writer、会话、项目和目标的宿主组件集成链路验证,本期本地采集验收通过。资源操作全面接线计划因超出原始需求撤销,未进入资源实现,不作为必做待办保留。本期无主站 API 或数据库行为;验收不表示已经提交、发布或上线。 + +入口核查以正式 Direct/Design GUI 可达链路为准。`src/main.tsx` 的旧 agent-chat/supervisor-chat 入口受 DEV 限制,正式 Direct 项目跳过旧 Runtime 恢复;旧 Runtime 的 file_ops/project_ops/ui.workflow 仍由开发入口和 CLI 使用,但不因这些函数存在就扩张本期正式 GUI 采集范围。通用画布导入/同步也需核实正式面板可达性,不能仅凭 App 内保留命令分支认定已经接入或必须接入。 + +| 已实现合同 | 验证证据 | 结果与边界 | +| --- | --- | --- | +| 基础创作链路的同一身份与关联 ID | 增强既有 `analytics_real_file_write_preserves_original_identity_and_failed_run_revision`;最终 `cargo +stable test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell analytics -- --test-threads=1` | 51 项全部通过,独立审查通过。同一 writer 串联会话/前台、创建/打开、首次受理、真实 Direct 文件事务、失败 run 保留成果、真实宿主 HTTP ready、checkpoint 保存和退出;核对事件唯一、必需 ID 非空且一致、run 成果版本、预览实际版本、focus 配对与真实 checkpoint 事实键。创建投递使用测试辅助入口,run 结果和构建产物由夹具提供;不等同于完整 GUI、Provider 或构建器端到端验证 | +| 最终前端与静态检查 | `clientAnalytics.test.tsx`、`directRunAnalytics.test.ts`、`uiEditorPage.test.ts`;shell `tsc --noEmit`、Rust fmt、编码/索引/diff | 前端 46 项全部通过,其余检查通过。最终收尾只增强测试和 `cfg(test)` 辅助,无产品逻辑变更,生产编译沿用下述合并后成功证据 | +| 12 类事件合同、必填 nullable、身份/来源/状态组合与安全整数 | `analytics::contract::tests` 的 5 项测试 | 通过;已删除未发布的独立策划快照合同和专属测试 | +| 路由切批、恢复去重、JSONL 原子发布、数量/大小/时间阈值、保留清理及项目目标资格 | `analytics::store::tests` 的 18 项测试 | 通过,使用真实临时目录;包括首次打开三态、跨实例索引回收、损坏项隔离、会话队列与写盘空间预留,以及新增的目标资格四项验证 | +| 非阻塞投递、内存条数与字节上限、真实线程封存 | 公开 writer 入队拒绝和最后 sender 关闭后的文件读回测试 | 通过;不等待业务退出,不保证强退零丢数 | +| 正式客户端模块集成 | `cargo +stable test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell analytics:: -- --test-threads=1` | 32 passed,0 failed;非独立替身 harness | +| 会话一次启动、实际退出、窗口并集、身份切分、迟到打开与重复回执 | `analytics::gui::tests` 的 4 项状态测试及原生接线独立复查 | 通过;纯内存临界区串行更新,启动身份发布与通知同步 | +| 未闭合会话恢复、活动实例保护、损坏/临时元数据回收 | `analytics::session::tests` 的 5 项真实文件锁与目录测试 | 通过;只对已取得旧 owner 锁的实例操作,未知所有权保持原样 | +| 创建成功、已有项目不误计、实际打开来源与卸载保护 | 前端 clientAnalytics/homeWebPreflight/useTemplateLibrary 共 27 项测试与 shell TypeScript 检查;同一 Rust 测试可执行文件的自动创建 3 项、初始化 2 项及认证会话 11 项回归 | 全部通过;内部 hydration 不计第二次打开,未宣称覆盖不存在的恢复/文件关联入口 | +| 生产 Tauri 入口编译与真实生命周期 | `cargo +stable build --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell`;临时配置目录启动真实客户端后正常关闭并读回 | 构建通过;start/end 各一条,end_reason=user_exit,单调时长 32297 ms,session.json 为 closed,未遗留该配置的子进程 | +| 格式与文档 | `cargo +stable fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --all -- --check`,文档索引、编码、差异检查 | 通过 | +| 策划阶段成果、审批幂等及失败边界 | 正式测试可执行文件 `agent::design_runtime::tests::` 19 项;`design_session` 筛选 3 项(其中 1 项重叠) | 全部通过;五阶段业务流程配合模拟 Provider,真实文件及 writer 读回 5 条阶段事件。后续模型失败仍保留一条;跨用户重放仍一条;拒绝与 checkpoint 失败零条。否定断言在同队列哨兵落盘后检查 | +| 策划审批生产入口 | `cargo +stable check --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell`;独立代码及验收审查 | 通过;Tauri 入口在资源加载前冻结身份。未调用外部付费模型;run 的阶段成果关联已由下述最新运行时测试覆盖 | +| 新项目首次观测提交、跨来源与批次清理不重复 | 新增四项真实 writer/项目测试,包含在上述 32 项中 | 通过;独立锁竞争后允许后续真实受理消费剩余资格。跨账号和双 writer 最多一条,未知/损坏/备用文件不重建,队列条数与字节拒绝不执行项目 I/O | +| 策划受理与失败、重放、审批拒绝和 checkpoint 失败 | 同一正式测试可执行文件 `agent::design_runtime::tests::` 22 项 | 通过;模拟 Provider 走真实运行时和文件队列。模型失败仍计已受理提交;旧命令重放不消费新出现资格,无效输入不消费后续用户资格 | +| Direct 新受理、账本重放与旧预算迁移 | 同一测试可执行文件 `agent::direct_execution::tests::` 21 项,生产入口独立代码审查及 `cargo +stable check` | 通过;新增的纯内存新受理标志不改变账本格式,恢复/迁移为 false,真实持久受理后才投递。未调用外部 Direct Provider 做完整创作 smoke | +| 六类创建入口与现有建项行为 | 自动创建 3 项、初始化 2 项、模板创建/缺失拒绝 2 项、Godot manifest/import 17 项;生产 check、格式检查 | 24 项通过;六个创建调用传实际项目目录,已有 manifest 不重授资格。当前仓库模板正文无 `.agent`;复制时保留同一 project_id 的项目仍按同一目标处理 | +| 两类 Agent 的运行身份、重试序号、恢复与终态去重 | 最新正式测试可执行文件 analytics 38 项、Design Runtime 24 项、Direct execution 23 项、Direct 终态分类 2 项 | 共 87 项通过;真实 writer/项目文件与模拟 Provider 验证。Design 等待正常完成、结构化失败、显式重试和阶段成果关联;恢复保留原身份、耗时未知;Direct 未完合同和取消不误记成功 | +| Direct 自动认证刷新后的最终尝试确认 | 上述 analytics 中 3 项候选测试;前端 directRunAnalytics 5 项及 clientAnalytics 11 项;shell `tsc --noEmit` | 通过;首次失败与最终成功同时暂存时只确认最终成功;未知尝试、discard、重复确认、16 项/1 MiB 上限和会话校验覆盖。前端 UUID 失败不回退、代次变化丢弃、桥接失败不阻塞或覆盖业务结果。独立代码审查通过 | +| Agent 运行结果生产入口与验收 | 最新 `cargo +stable check --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell`;fmt、编码、索引、差异检查;独立条款验收 | 全部通过;生产 check 有既存告警,无编译错误。未新增上传、加密、网络请求或业务埋点 I/O 等待;运行结果临时计划已融合删除 | +| Direct 宿主文件与补丁成果、原身份及 run 成果关联 | `cargo +stable test ... analytics -- --test-threads=1` 43 项;同正式测试可执行文件 `agent::direct_patch::tests::` 6 项、`bridge_write_file` 4 项、`queued_write_rechecks_the_original_lease_after_project_lock_release` 1 项、`agent::direct_execution::tests::` 23 项 | 去重 76 项全部通过。真实写许可/文件/JSONL验证新内容记成果、同内容不记、原用户 A 归属;真实 bundled patch 两文件成功仅一条/count=2,随后部分失败不覆盖成果。失败 run 的关联为 writer 集成测试加生产接线审查,未宣称完整 Provider 失败流程;两个成功版本取最新经代码审查 | +| Direct 成果生产编译、故障隔离及条款验收 | 最新生产 `cargo +stable check`、fmt/编码/索引/diff及独立代码与条款审查 | 全部通过;读取未知/队列拒绝不改变工具结果,不新增业务版本或同步埋点写盘。测试夹具已补真实合同与 config 父目录;Windows patch smoke 将既有 `target/debug/coding-agent` 完整复制至测试 exe 相邻的 `target/debug/deps/coding-agent`,使用校验通过的 0.155.1 执行器,未放宽版本校验或更改全局 Codex。成果采集临时计划已融合删除 | +| GUI 人工编辑、checkpoint 与原生 UI 成果 | 最新 `cargo +stable test ... analytics -- --test-threads=1` 45 项;同正式测试二进制 UI persistence 14 项、bridge_write_file 4 项 | 合计 63 项通过。新真实文件/writer 测试覆盖变化写入、同内容保存、删除与失败、完整及失败 checkpoint、UI Saved/Unchanged/Conflict/错误和原用户归属。checkpoint 失败夹具使用真实项目中被普通文件占用的检查点目录,不把业务允许初始化的缺失目录当作失败 | +| UI 逻辑保存及故障隔离 | 前端 clientAnalytics/uiEditorPage 两文件 41 项、shell `tsc --noEmit`,生产 `cargo +stable check`、fmt/编码/索引/diff,独立代码审查及条款验收 | 全部通过;普通手动保存、自动保存、组合生成成功/失败、重复回调、桥接失败与冻结身份已验证。保存不等待埋点;没有更改业务 DTO 或上传/加密。临时计划已融合删除;未执行 GUI 交互或前端到原生 UI 保存命令的完整跨层 smoke | +| 正式 Web 预览实际 ready、实例寿命与身份 | 最新正式测试二进制 analytics 51 项(含新增六项真实 loopback/JSONL 测试)、既有本地预览及 registry/lifecycle 21 项、Direct validation 6 项 | 合计 78 项通过。覆盖真实 GUI helper 自动探测、user/agent 来源、同版本新实例、原用户、空/缺失入口、HTTP失败/空响应、请求期间版本漂移、停止/替换/取消。一个旧 Runtime 模拟模型测试在并发编译时触发原有 2 秒等待超时,编译结束后单独 exact 复跑通过,未修改断言 | +| 预览前端来源、生产编译及条款验收 | 合并 master 后 `appSurface.test.ts --threads=false -t 'preview\|预览\|play request\|run local\|local game'` 104 项、shell tsc、生产 `cargo +stable check`、fmt/编码/索引/diff及独立验收 | 全部通过;旧草稿自动预览不传 user,不据任意外部 URL 推断项目成果。Direct 原 run capture 与临时 guard 经独立接线审查;实测 host HTTP 可访问性不等价于完整 Chrome 双端验证。无上传、外部探测或新增业务版本;临时计划已融合删除 | + +环境限制:本机固定 1.98.1 工具链缺可用 cargo,实际显式使用 stable(1.96.0)完成编译测试,未更改仓库固定版本。真实 GUI smoke 使用隐藏窗口,只证明启动与正常退出,不证明实际焦点操作;窗口并集、最小化和重复通知以状态测试验证,锁屏/休眠为已知观测限制。真实付费 Provider 和完整 GUI run smoke 未执行,Runtime 模拟 Provider 与前端 helper 测试不等价于真实模型验证。Direct 未完成交付合同的 guard 释放可能将账本中断,本版不改变其业务恢复行为,不承诺所有认证刷新都能继续生成;埋点不会将此类中断报告记为成功。人工和 UI 保存已有真实业务函数/文件/JSONL及前端 hook 验证,尚无完整 GUI 跨层操作 smoke;预览已有真实宿主 HTTP 和 JSONL 验证,未运行完整 Chrome 双端验证。