diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 99177a4f4..2bd540024 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -2205,6 +2205,19 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( .await } +/// standalone 图片生成的**精确动作身份材料**。 +/// +/// 这份材料既是动作指纹(`actionFingerprint`)的来源,也是 durable 输出槽身份 +/// (`runId`,见 [`standalone_platform_art_generation_runtime_context`])的来源: +/// 同一个精确动作必须落到同一个槽与同一份账本(复用 `operationId`,不二次 POST), +/// 而任何输入不同(提示词、输出路径、比例、尺寸、类型、标签、严格切片)都是另一个 +/// 动作,必须各自独立成槽,才能在同一项目里同时在途。 +/// +/// **字段集合与取值方式必须与升级前逐字节一致**:升级前遗留账本里持久化的 +/// `actionFingerprint` 就是这个材料的历史哈希,改动材料会让旧账本无法按精确动作被 +/// 识别与迁移(见 `adopt_legacy_standalone_platform_art_generation_runtime_state_at`)。 +/// 已知边界:`slice_count` 不进身份(与升级前一致),仅切片数不同的两条图集请求仍落到 +/// 同一槽,第二条在账本请求正文校验处失败关闭,不会二次 POST。 #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct StandalonePlatformArtGenerationFingerprintMaterial<'a> { @@ -2218,6 +2231,21 @@ struct StandalonePlatformArtGenerationFingerprintMaterial<'a> { require_slices: bool, } +/// 把输出路径收口成稳定的旧槽材料:空路径与未指定路径都落到 `(automatic-output)`, +/// 其余按项目内相对路径规范化。这与升级前的槽材料逐字节一致,只用于定位旧账本。 +fn legacy_standalone_platform_art_generation_output_slot( + options: &PlatformArtAssetGenerationOptions, +) -> Result { + Ok(options + .output_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(normalize_relative_path) + .transpose()? + .unwrap_or_else(|| "(automatic-output)".to_string())) +} + fn standalone_platform_art_generation_runtime_context( prompt: &str, options: &PlatformArtAssetGenerationOptions, @@ -2228,33 +2256,23 @@ fn standalone_platform_art_generation_runtime_context( } else { "manual-canvas-asset-generate" }; - let output_slot = options - .output_path - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(normalize_relative_path) - .transpose()? - .unwrap_or_else(|| "(automatic-output)".to_string()); - let slot_bytes = serde_json::to_vec(&serde_json::json!({ - "outputPath": output_slot, - "requireSlices": require_slices, - })) - .map_err(|error| format!("序列化 standalone 图片生成输出槽失败:{error}"))?; - let run_id = format!("slot-{:x}", Sha256::digest(slot_bytes)); + let identity_bytes = serde_json::to_vec(&StandalonePlatformArtGenerationFingerprintMaterial { + prompt, + output_path: options.output_path.as_deref(), + aspect_ratio: &options.aspect_ratio, + image_size: &options.image_size, + asset_kind: &options.asset_kind, + asset_label: &options.asset_label, + replace_existing: options.replace_existing, + require_slices, + }) + .map_err(|error| format!("序列化 standalone 图片生成动作身份失败:{error}"))?; + let action_fingerprint = format!("{:x}", Sha256::digest(&identity_bytes)); + // durable 输出槽身份就是这个精确动作的稳定唯一身份:同项目内不同动作 + // (不同 prompt / 素材名 / 参数)各自独立成槽,可以同时在途;重复提交同一精确动作 + // 命中同一槽与同一账本,因此仍然复用原 operationId,不二次 POST。 + let run_id = format!("slot-{action_fingerprint}"); let identity = format!("{agent_id}:{run_id}"); - let fingerprint_bytes = - serde_json::to_vec(&StandalonePlatformArtGenerationFingerprintMaterial { - prompt, - output_path: options.output_path.as_deref(), - aspect_ratio: &options.aspect_ratio, - image_size: &options.image_size, - asset_kind: &options.asset_kind, - asset_label: &options.asset_label, - replace_existing: options.replace_existing, - require_slices, - }) - .map_err(|error| format!("序列化 standalone 图片生成动作指纹失败:{error}"))?; Ok(PlatformArtGenerationRuntimeContext { agent_id: agent_id.to_string(), task_id: identity.clone(), @@ -2262,10 +2280,25 @@ fn standalone_platform_art_generation_runtime_context( run_id, source: "tauri-command".to_string(), action_id: identity, - action_fingerprint: format!("{:x}", Sha256::digest(fingerprint_bytes)), + action_fingerprint, }) } +/// 升级前的槽身份材料:只由 `{outputPath, requireSlices}` 派生,因此同一项目里 +/// 所有图片类生成共用一个槽。这个函数只用于定位升级前遗留的账本,不属于新的身份规则。 +fn legacy_standalone_platform_art_generation_run_id( + options: &PlatformArtAssetGenerationOptions, + require_slices: bool, +) -> Result { + let output_slot = legacy_standalone_platform_art_generation_output_slot(options)?; + let slot_bytes = serde_json::to_vec(&serde_json::json!({ + "outputPath": output_slot, + "requireSlices": require_slices, + })) + .map_err(|error| format!("序列化 standalone 图片生成旧输出槽失败:{error}"))?; + Ok(format!("slot-{:x}", Sha256::digest(slot_bytes))) +} + fn resolve_standalone_platform_art_generation_result( root: &Path, runtime_context: Option<&PlatformArtGenerationRuntimeContext>, @@ -2459,6 +2492,19 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( root, runtime_context, )?; + // 一次性兼容:升级前的槽身份只由 {outputPath, requireSlices} 派生,升级后同一精确 + // 动作会指向新路径。若旧槽里的账本仍属于本次精确动作,就在任何远端 POST 前把它迁移 + // 到新身份路径,继续复用原 operationId;属于其他动作的旧账本原样保留,由对应动作 + // 自己迁移,既不阻塞新动作也不丢弃已受理的计费操作。 + if super::external_generation_state::is_standalone_platform_art_generation_runtime_context( + runtime_context, + ) { + super::external_generation_state::adopt_legacy_standalone_platform_art_generation_runtime_state_at( + root, + runtime_context, + &legacy_standalone_platform_art_generation_run_id(options, require_slices)?, + )?; + } { // Direct Codex chat persists the user turn concurrently with the first // platform-art request. Both operations are short-lived project writes; @@ -7787,79 +7833,6 @@ mod canvas_generation_tests { ColorType, ImageEncoder, }; - #[test] - fn standalone_generation_binds_complete_request_to_stable_output_slot() { - let options = PlatformArtAssetGenerationOptions { - output_path: Some("assets/manual-art.png".to_string()), - aspect_ratio: "16:9".to_string(), - image_size: "2K".to_string(), - asset_kind: "game-background".to_string(), - asset_label: "手工背景".to_string(), - replace_existing: true, - slice_count: None, - }; - let ordinary = - standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) - .expect("ordinary standalone context"); - let repeated = - standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) - .expect("repeat standalone context"); - let strict = - standalone_platform_art_generation_runtime_context("完整生成提示词", &options, true) - .expect("strict standalone context"); - assert_eq!(ordinary, repeated); - assert_ne!(ordinary.run_id, strict.run_id); - assert_eq!(ordinary.source, "tauri-command"); - assert_eq!(strict.source, "tauri-command"); - assert_eq!( - ordinary.task_id, - format!("{}:{}", ordinary.agent_id, ordinary.run_id) - ); - assert_eq!(ordinary.task_id, ordinary.session_id); - assert_eq!(ordinary.task_id, ordinary.action_id); - - let different_prompt = - standalone_platform_art_generation_runtime_context("另一个生成提示词", &options, false) - .expect("different prompt context"); - assert_eq!(ordinary.run_id, different_prompt.run_id); - assert_ne!( - ordinary.action_fingerprint, - different_prompt.action_fingerprint - ); - - let mut changed_options = Vec::new(); - let mut changed = options.clone(); - changed.output_path = Some("assets/another-art.png".to_string()); - changed_options.push(changed); - let mut changed = options.clone(); - changed.aspect_ratio = "1:1".to_string(); - changed_options.push(changed); - let mut changed = options.clone(); - changed.image_size = "1K".to_string(); - changed_options.push(changed); - let mut changed = options.clone(); - changed.asset_kind = "ui-prototype".to_string(); - changed_options.push(changed); - let mut changed = options.clone(); - changed.asset_label = "另一个标签".to_string(); - changed_options.push(changed); - let mut changed = options.clone(); - changed.replace_existing = false; - changed_options.push(changed); - for changed in changed_options { - let context = standalone_platform_art_generation_runtime_context( - "完整生成提示词", - &changed, - false, - ) - .expect("changed option context"); - assert_ne!( - ordinary.action_fingerprint, context.action_fingerprint, - "every option field must participate in the action fingerprint" - ); - } - } - #[test] fn generation_kind_catalog_normalizes_spec_onto_the_verified_icon_spec() { // 目录就是两条调用路径共同的可生成集合,必须逐字固定。 @@ -8145,9 +8118,12 @@ mod canvas_generation_tests { let second = tokio::time::timeout( Duration::from_secs(2), crate::assets::with_external_editor_api_credentials(second_credentials, async move { + // 同一个精确动作(同一 prompt + 同一 options):必须命中同一个 durable 输出槽, + // 在任何远端 POST 前被拒;不同动作可以同时在途,见 + // `concurrent_distinct_standalone_generations_both_succeed_with_one_post_each`。 generate_platform_art_asset_with_options_at( &second_root, - "不同的第二个手工请求", + "第一个手工请求", &[], &options, ) @@ -8326,6 +8302,688 @@ mod canvas_generation_tests { ); } + #[test] + fn standalone_generation_binds_each_exact_request_to_its_own_stable_slot() { + let options = PlatformArtAssetGenerationOptions { + output_path: Some("assets/manual-art.png".to_string()), + aspect_ratio: "16:9".to_string(), + image_size: "2K".to_string(), + asset_kind: "game-background".to_string(), + asset_label: "手工背景".to_string(), + replace_existing: true, + slice_count: None, + }; + let ordinary = + standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) + .expect("ordinary standalone context"); + let repeated = + standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) + .expect("repeat standalone context"); + let strict = + standalone_platform_art_generation_runtime_context("完整生成提示词", &options, true) + .expect("strict standalone context"); + assert_eq!(ordinary, repeated); + assert_ne!(ordinary.run_id, strict.run_id); + assert_eq!(ordinary.source, "tauri-command"); + assert_eq!(strict.source, "tauri-command"); + assert_eq!( + ordinary.task_id, + format!("{}:{}", ordinary.agent_id, ordinary.run_id) + ); + assert_eq!(ordinary.task_id, ordinary.session_id); + assert_eq!(ordinary.task_id, ordinary.action_id); + // 槽身份就是这条精确动作的稳定唯一身份,不再是"按输出路径合并"的共享槽。 + assert_eq!( + ordinary.run_id, + format!("slot-{}", ordinary.action_fingerprint), + "durable 输出槽身份必须等于该精确动作的身份" + ); + + // 不同提示词是两条不同动作:必须各自独立成槽,才能在同一项目里同时在途。 + let different_prompt = + standalone_platform_art_generation_runtime_context("另一个生成提示词", &options, false) + .expect("different prompt context"); + assert_ne!( + ordinary.run_id, different_prompt.run_id, + "不同 prompt 不得共用同一个 durable 输出槽" + ); + assert_ne!( + ordinary.action_fingerprint, + different_prompt.action_fingerprint + ); + + // 同一条动作重复构造必须逐字节稳定(幂等复用 operationId 的前提)。 + assert_eq!( + different_prompt, + standalone_platform_art_generation_runtime_context("另一个生成提示词", &options, false) + .expect("repeat different prompt context") + ); + + let mut changed_options = Vec::new(); + let mut changed = options.clone(); + changed.output_path = Some("assets/another-art.png".to_string()); + changed_options.push(changed); + let mut changed = options.clone(); + changed.aspect_ratio = "1:1".to_string(); + changed_options.push(changed); + let mut changed = options.clone(); + changed.image_size = "1K".to_string(); + changed_options.push(changed); + let mut changed = options.clone(); + changed.asset_kind = "ui-prototype".to_string(); + changed_options.push(changed); + let mut changed = options.clone(); + changed.asset_label = "另一个标签".to_string(); + changed_options.push(changed); + let mut changed = options.clone(); + changed.replace_existing = false; + changed_options.push(changed); + for changed in changed_options { + let context = standalone_platform_art_generation_runtime_context( + "完整生成提示词", + &changed, + false, + ) + .expect("changed option context"); + assert_ne!( + ordinary.action_fingerprint, context.action_fingerprint, + "every option field must participate in the action fingerprint" + ); + assert_ne!( + ordinary.run_id, context.run_id, + "every option field must also move the durable output slot" + ); + } + + // 旧槽公式仍可重现:它只用于定位升级前遗留的账本,必须与新身份区分开。 + let legacy = legacy_standalone_platform_art_generation_run_id(&options, false) + .expect("legacy output slot"); + assert_eq!( + legacy, + legacy_standalone_platform_art_generation_run_id(&options, false) + .expect("repeat legacy output slot") + ); + assert_ne!(legacy, ordinary.run_id); + assert_ne!( + legacy, + legacy_standalone_platform_art_generation_run_id(&options, true) + .expect("strict legacy output slot") + ); + } + + #[test] + fn distinct_standalone_actions_hold_independent_durable_output_slots() { + let temporary = tempfile::tempdir().expect("create independent slot project"); + let root = temporary.path(); + init_local_game_project_at(root, "manual-independent-slots", "独立输出槽测试") + .expect("init independent slot project"); + let options = PlatformArtAssetGenerationOptions { + output_path: None, + asset_kind: "icon-spec".to_string(), + asset_label: "图标规范".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let first_prompt = build_platform_art_asset_prompt("第一个手工请求", &[], &options); + let second_prompt = build_platform_art_asset_prompt("第二个手工请求", &[], &options); + let first = + standalone_platform_art_generation_runtime_context(&first_prompt, &options, false) + .expect("first standalone slot context"); + let second = + standalone_platform_art_generation_runtime_context(&second_prompt, &options, false) + .expect("second standalone slot context"); + assert_ne!(first.run_id, second.run_id); + + let first_guard = + super::external_generation_state::acquire_durable_platform_art_generation_guard( + root, &first, + ) + .expect("acquire first standalone slot") + .expect("standalone context requires a durable slot guard"); + let second_guard = + super::external_generation_state::acquire_durable_platform_art_generation_guard( + root, &second, + ) + .expect("a different exact action must not conflict with an in-flight slot") + .expect("standalone context requires a durable slot guard"); + let repeated_same_action = + super::external_generation_state::acquire_durable_platform_art_generation_guard( + root, &first, + ) + .err() + .expect("the same exact action must still conflict before any remote POST"); + assert!( + repeated_same_action.contains("任何远端 POST 前拒绝并发请求"), + "{repeated_same_action}" + ); + drop(first_guard); + drop(second_guard); + assert!( + super::external_generation_state::acquire_durable_platform_art_generation_guard( + root, &first, + ) + .expect("reacquire released standalone slot") + .is_some() + ); + } + + /// 并发夹具:两条 POST 必须同时到达才放行,用来证明两条不同动作真的同时在途。 + #[derive(Default)] + struct ConcurrentPostBarrier { + arrived: std::sync::Mutex, + released: std::sync::Condvar, + both_in_flight: std::sync::atomic::AtomicBool, + } + + impl ConcurrentPostBarrier { + fn arrive(&self) { + let mut arrived = self.arrived.lock().expect("lock concurrent POST arrivals"); + *arrived += 1; + if *arrived >= 2 { + self.both_in_flight + .store(true, std::sync::atomic::Ordering::SeqCst); + self.released.notify_all(); + return; + } + let (arrived, _) = self + .released + .wait_timeout(arrived, Duration::from_secs(10)) + .expect("wait for the second concurrent POST"); + assert!( + *arrived >= 2, + "两条不同动作的 POST 必须同时在途,否则第一条已经串行等待了第二条" + ); + } + + fn both_in_flight(&self) -> bool { + self.both_in_flight + .load(std::sync::atomic::Ordering::SeqCst) + } + } + + fn serve_concurrent_standalone_generation_request( + stream: &mut std::net::TcpStream, + base_url: &str, + barrier: &ConcurrentPostBarrier, + post_index: &std::sync::atomic::AtomicUsize, + posts: &std::sync::Mutex>, + ) { + let request = read_test_http_request(stream); + if request.starts_with("POST /api/external/v1/editor/images/generations ") { + let index = post_index.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + posts + .lock() + .expect("record concurrent generation posts") + .push(request.clone()); + barrier.arrive(); + write_test_json_response( + stream, + "202 Accepted", + &serde_json::json!({ + "data": { + "operationId": format!("concurrent-operation-{index}"), + "status": "queued", + "pollAfterMs": 250, + } + }), + ); + return; + } + if let Some(operation_id) = request + .split_whitespace() + .nth(1) + .and_then(|path| path.strip_prefix("/api/external/v1/generations/")) + .map(str::to_string) + { + write_test_json_response( + stream, + "200 OK", + &serde_json::json!({ + "data": { + "operationId": operation_id, + "status": "completed", + "pollAfterMs": 0, + "result": { + "resource": { + "resourceId": format!("resource-{operation_id}"), + // durable 账本只允许相对媒体路径或 objectKey, + // 下载再经 read-url 换签,与真实平台链路一致。 + "objectKey": format!("manual-concurrent-{operation_id}.png"), + } + } + } + }), + ); + return; + } + if request.starts_with("GET /api/external/v1/assets/read-url?") { + let object_key = request + .split_whitespace() + .nth(1) + .and_then(|path| path.split("objectKey=").nth(1)) + .map(str::to_string) + .expect("read-url fixture must carry an objectKey"); + write_test_json_response( + stream, + "200 OK", + &serde_json::json!({ + "read": {"signedUrl": format!("{base_url}/{object_key}")} + }), + ); + return; + } + if request.starts_with("GET /manual-concurrent-concurrent-operation-") { + let png = rgba_test_png(u8::MAX).bytes; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + png.len() + ) + .expect("write concurrent generation png headers"); + stream + .write_all(&png) + .expect("write concurrent generation png body"); + return; + } + if request.starts_with("GET /api/external/v1/editor/projects ") { + // 项目绑定已由 `install_test_external_project_binding` 预置,远端只需回认同一组身份。 + write_test_json_response( + stream, + "200 OK", + &serde_json::json!({ + "data": {"projects": [{ + "projectId": "manual-test-canvas", + "title": "并发手工生成画布", + }]} + }), + ); + return; + } + if request.starts_with("GET /api/external/v1/editor/assets/library ") { + write_test_json_response( + stream, + "200 OK", + &serde_json::json!({ + "data": {"library": {"folders": [{ + "folderId": "manual-test-assets", + "label": "并发手工生成素材", + }]}} + }), + ); + return; + } + panic!("unexpected concurrent standalone generation fixture request: {request}"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_distinct_standalone_generations_both_succeed_with_one_post_each() { + let temporary = tempfile::tempdir().expect("create concurrent generation project"); + let root = temporary.path(); + init_local_game_project_at(root, "manual-concurrent-generations", "并发手工生成测试") + .expect("init concurrent generation project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow concurrent standalone generation"); + + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind concurrent generation fixture"); + listener + .set_nonblocking(true) + .expect("set concurrent generation fixture nonblocking"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let api_key = "manual-concurrent-key"; + install_test_external_project_binding(root, &base_url, api_key); + + let barrier = std::sync::Arc::new(ConcurrentPostBarrier::default()); + let post_index = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let posts = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let server_base_url = base_url.clone(); + let server_barrier = barrier.clone(); + let server_post_index = post_index.clone(); + let server_posts = posts.clone(); + let server_stop = stop.clone(); + let server = std::thread::spawn(move || { + let mut handlers = Vec::new(); + loop { + if server_stop.load(std::sync::atomic::Ordering::SeqCst) { + break; + } + match listener.accept() { + Ok((mut stream, _)) => { + let base_url = server_base_url.clone(); + let barrier = server_barrier.clone(); + let post_index = server_post_index.clone(); + let posts = server_posts.clone(); + handlers.push(std::thread::spawn(move || { + serve_concurrent_standalone_generation_request( + &mut stream, + &base_url, + &barrier, + &post_index, + &posts, + ); + })); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + } + Err(error) => { + panic!("accept concurrent standalone generation request: {error}") + } + } + } + for handler in handlers { + handler.join().expect("join concurrent fixture handler"); + } + }); + + // 两条动作都用「自动输出」(outputPath 为空),这正是工具栏除首次图标规范外的常态: + // 升级前它们共用同一个 (automatic-output) 槽,因此第二条必然被拒;升级后各自成槽。 + let first_options = PlatformArtAssetGenerationOptions { + output_path: None, + asset_kind: "icon-spec".to_string(), + asset_label: "并发素材一".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let second_options = PlatformArtAssetGenerationOptions { + output_path: None, + asset_kind: "icon-spec".to_string(), + asset_label: "并发素材二".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let first_credentials = crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + api_key.to_string(), + ); + let second_credentials = crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + api_key.to_string(), + ); + let first_root = root.to_path_buf(); + let second_root = root.to_path_buf(); + let first = tokio::spawn(async move { + crate::assets::with_external_editor_api_credentials(first_credentials, async move { + generate_platform_art_asset_with_options_at( + &first_root, + "第一个并发手工请求", + &[], + &first_options, + ) + .await + }) + .await + }); + let second = tokio::spawn(async move { + crate::assets::with_external_editor_api_credentials(second_credentials, async move { + generate_platform_art_asset_with_options_at( + &second_root, + "第二个并发手工请求", + &[], + &second_options, + ) + .await + }) + .await + }); + let first = tokio::time::timeout(Duration::from_secs(30), first) + .await + .expect("first concurrent generation must finish") + .expect("join first concurrent generation"); + let second = tokio::time::timeout(Duration::from_secs(30), second) + .await + .expect("second concurrent generation must finish") + .expect("join second concurrent generation"); + let first = first.expect("first standalone generation must succeed"); + let second = second.expect("second standalone generation must succeed"); + + assert!( + barrier.both_in_flight(), + "两条不同手工生成必须同时在途,两条 POST 必须同时到达远端" + ); + assert_eq!( + post_index.load(std::sync::atomic::Ordering::SeqCst), + 2, + "每条精确动作只允许一次远端 POST" + ); + let posts = posts.lock().expect("read concurrent generation posts"); + assert_eq!(posts.len(), 2); + assert!( + posts.iter().any(|post| post.contains("第一个并发手工请求")), + "{posts:#?}" + ); + assert!( + posts.iter().any(|post| post.contains("第二个并发手工请求")), + "{posts:#?}" + ); + drop(posts); + assert_ne!(first.resource_id, second.resource_id); + let installed = std::fs::read_dir(root.join("assets/canvas-generated")) + .expect("read concurrent generation output directory") + .map(|entry| { + entry + .expect("read concurrent generation output entry") + .file_name() + .to_string_lossy() + .to_string() + }) + .collect::>(); + assert_eq!( + installed.len(), + 2, + "两条并发生成必须各自落地一个输出文件:{installed:#?}" + ); + for resource_id in [&first.resource_id, &second.resource_id] { + let resource_id = resource_id + .as_deref() + .expect("fixture returns a resourceId"); + assert!( + installed + .iter() + .any(|file_name| file_name.contains(resource_id)), + "并发生成结果必须按自己的 resourceId 落盘:{resource_id}: {installed:#?}" + ); + } + stop.store(true, std::sync::atomic::Ordering::SeqCst); + server.join().expect("join concurrent fixture"); + } + + #[test] + fn legacy_output_slot_ledger_is_adopted_by_the_same_exact_action_only() { + let temporary = tempfile::tempdir().expect("create legacy slot project"); + let root = temporary.path(); + init_local_game_project_at(root, "manual-legacy-slot", "旧输出槽兼容测试") + .expect("init legacy slot project"); + let options = PlatformArtAssetGenerationOptions { + output_path: None, + asset_kind: "icon-spec".to_string(), + asset_label: "图标规范".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let prompt = build_platform_art_asset_prompt("旧槽兼容手工请求", &[], &options); + let context = standalone_platform_art_generation_runtime_context(&prompt, &options, false) + .expect("current standalone context"); + let legacy_run_id = legacy_standalone_platform_art_generation_run_id(&options, false) + .expect("legacy standalone run id"); + assert_ne!(legacy_run_id, context.run_id); + let legacy_identity = format!("{}:{legacy_run_id}", context.agent_id); + let legacy_context = PlatformArtGenerationRuntimeContext { + task_id: legacy_identity.clone(), + session_id: legacy_identity.clone(), + run_id: legacy_run_id.clone(), + action_id: legacy_identity, + ..context.clone() + }; + assert!( + super::external_generation_state::is_standalone_platform_art_generation_runtime_context( + &legacy_context + ), + "升级前的 slot- 槽身份必须继续被接受,不得 panic 或直接失败关闭" + ); + let access = ExternalEditorBindingAccess::for_developer( + "https://editor.example.test", + "legacy-slot-key", + ) + .expect("create legacy slot binding access"); + let (legacy_state, created) = prepare_platform_art_generation_runtime_state( + root, + &legacy_context, + "/api/external/v1/editor/images/generations", + "manual-test-canvas", + &prompt, + &serde_json::json!({ + "prompt": prompt, + "kind": "spec", + "projectId": "manual-test-canvas", + "assetFolderId": "manual-test-assets", + "referenceImageSrcs": [] + }), + &access, + ) + .expect("prepare legacy slot ledger"); + assert!(created); + let legacy_state = mark_platform_art_generation_runtime_accepted( + root, + legacy_state, + "legacy-slot-operation", + 1_500, + ) + .expect("mark legacy slot operation accepted"); + let legacy_idempotency_key = + platform_art_generation_runtime_idempotency_key(&legacy_state).to_string(); + assert_eq!( + read_platform_art_generation_runtime_state(root, &legacy_context) + .expect("旧格式账本必须仍然可读") + .as_ref() + .and_then( + super::external_generation_state::platform_art_generation_runtime_operation_id_for_test + ), + Some("legacy-slot-operation") + ); + + assert!( + super::external_generation_state::adopt_legacy_standalone_platform_art_generation_runtime_state_at( + root, + &context, + &legacy_run_id, + ) + .expect("adopt legacy slot ledger"), + "同一精确动作的旧槽账本必须被迁移到新身份路径" + ); + let migrated = read_platform_art_generation_runtime_state(root, &context) + .expect("read migrated ledger") + .expect("migrated ledger exists"); + assert_eq!( + super::external_generation_state::platform_art_generation_runtime_operation_id_for_test( + &migrated + ), + Some("legacy-slot-operation") + ); + assert_eq!( + platform_art_generation_runtime_idempotency_key(&migrated), + legacy_idempotency_key + ); + assert_eq!( + super::external_generation_state::platform_art_generation_runtime_run_id_for_test( + &migrated + ), + context.run_id + ); + assert_eq!( + super::external_generation_state::platform_art_generation_runtime_action_fingerprint_for_test( + &migrated + ), + context.action_fingerprint + ); + assert!( + !game_creator_agent_runtime_external_generation_exists( + root, + &context.agent_id, + &legacy_run_id + ), + "迁移后旧路径上的账本必须已清理" + ); + assert!( + !super::external_generation_state::adopt_legacy_standalone_platform_art_generation_runtime_state_at( + root, + &context, + &legacy_run_id, + ) + .expect("second adoption is a no-op"), + "重复迁移必须是幂等空操作" + ); + + // 另一个精确动作的旧槽账本不得被本次动作消费、改写或删除。 + let other_options = PlatformArtAssetGenerationOptions { + output_path: None, + asset_kind: "icon-spec".to_string(), + asset_label: "另一个图标规范".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let other_prompt = + build_platform_art_asset_prompt("另一个旧槽手工请求", &[], &other_options); + let other_context = standalone_platform_art_generation_runtime_context( + &other_prompt, + &other_options, + false, + ) + .expect("other standalone context"); + let other_legacy_run_id = + legacy_standalone_platform_art_generation_run_id(&other_options, false) + .expect("other legacy standalone run id"); + let other_legacy_identity = format!("{}:{other_legacy_run_id}", other_context.agent_id); + let other_legacy_context = PlatformArtGenerationRuntimeContext { + task_id: other_legacy_identity.clone(), + session_id: other_legacy_identity.clone(), + run_id: other_legacy_run_id.clone(), + action_id: other_legacy_identity, + ..other_context.clone() + }; + prepare_platform_art_generation_runtime_state( + root, + &other_legacy_context, + "/api/external/v1/editor/images/generations", + "manual-test-canvas", + &other_prompt, + &serde_json::json!({ + "prompt": other_prompt, + "kind": "spec", + "projectId": "manual-test-canvas", + "assetFolderId": "manual-test-assets", + "referenceImageSrcs": [] + }), + &access, + ) + .expect("prepare other legacy slot ledger"); + assert!( + !super::external_generation_state::adopt_legacy_standalone_platform_art_generation_runtime_state_at( + root, + &context, + &other_legacy_run_id, + ) + .expect("other action legacy slot is not adopted"), + "属于其他精确动作的旧槽账本不得被本次动作迁移" + ); + assert!( + game_creator_agent_runtime_external_generation_exists( + root, + &other_context.agent_id, + &other_legacy_run_id + ), + "属于其他精确动作的旧槽账本必须原样保留" + ); + assert!( + read_platform_art_generation_runtime_state(root, &other_legacy_context) + .expect("other legacy ledger stays readable") + .is_some() + ); + } + #[tokio::test] async fn external_canvas_context_is_scoped_by_account_and_local_project_id() { let temporary = tempfile::tempdir().expect("create account binding project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index a6075cda0..03b0dcac1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -1074,6 +1074,102 @@ pub(in crate::agent) fn remove_platform_art_generation_runtime_state_at( } } +/// 一次性兼容:升级前 standalone 槽身份只由 `{outputPath, requireSlices}` 派生, +/// 同一项目所有图片类生成共用一个槽;升级后槽身份按精确动作派生,路径随之变化。 +/// +/// 若旧槽路径上的账本仍然属于本次精确动作(`agentId` 与 `actionFingerprint` 都与 +/// 当前上下文一致),就在项目写锁内把它迁移到新身份路径:保留原 `idempotencyKey` +/// 与 `operationId`,避免同一精确动作在升级后二次 POST 计费。旧账本属于其他动作时 +/// 原样保留(不迁移、不删除、不阻塞),由对应动作自己的请求迁移。 +/// +/// 返回 `Ok(false)` 表示没有需要迁移的旧账本。任何身份无法安全解释的情形都失败关闭。 +pub(super) fn adopt_legacy_standalone_platform_art_generation_runtime_state_at( + root: &Path, + context: &PlatformArtGenerationRuntimeContext, + legacy_run_id: &str, +) -> Result { + if !is_standalone_platform_art_generation_runtime_context(context) + || legacy_run_id == context.run_id + || !is_lowercase_sha256(legacy_run_id.strip_prefix("slot-").unwrap_or_default()) + { + return Ok(false); + } + // 与账本创建互斥:迁移必须在同一把项目写锁内完成,否则两个调用可能同时把同一份 + // 旧账本迁移到新路径,或与新建账本互相覆盖。 + let _claim_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "canvas.asset_generate.runtime.claim", + )?; + if game_creator_agent_runtime_external_generation_exists( + root, + &context.agent_id, + &context.run_id, + ) { + // 新身份账本已经存在:旧账本不属于本次动作的权威状态,保持两边各自的身份。 + return Ok(false); + } + let legacy_relative_path = + platform_art_generation_runtime_relative_path(&context.agent_id, legacy_run_id); + let Some(legacy_state) = + read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &legacy_relative_path, + "External Editor 生成账本", + PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES, + )? + else { + return Ok(false); + }; + if legacy_state.agent_id != context.agent_id + || legacy_state.run_id != legacy_run_id + || legacy_state.action_fingerprint != context.action_fingerprint + { + // 旧槽里是另一个精确动作的账本:它仍归那个动作所有,本次调用不得消费、改写或删除它。 + return Ok(false); + } + let legacy_identity = format!("{}:{legacy_run_id}", context.agent_id); + let legacy_context = PlatformArtGenerationRuntimeContext { + task_id: legacy_identity.clone(), + session_id: legacy_identity.clone(), + run_id: legacy_run_id.to_string(), + action_id: legacy_identity, + ..context.clone() + }; + let Some(mut migrated) = read_platform_art_generation_runtime_state(root, &legacy_context)? + else { + return Ok(false); + }; + migrated.run_id = context.run_id.clone(); + migrated.task_id = context.task_id.clone(); + migrated.session_id = context.session_id.clone(); + migrated.action_id = context.action_id.clone(); + migrated.updated_at = unix_timestamp(); + write_platform_art_generation_runtime_state(root, &migrated)?; + remove_platform_art_generation_runtime_state_at(root, &context.agent_id, legacy_run_id)?; + Ok(true) +} + +#[cfg(test)] +pub(super) fn platform_art_generation_runtime_operation_id_for_test( + state: &PlatformArtGenerationRuntimeState, +) -> Option<&str> { + state.operation_id.as_deref() +} + +#[cfg(test)] +pub(super) fn platform_art_generation_runtime_run_id_for_test( + state: &PlatformArtGenerationRuntimeState, +) -> &str { + &state.run_id +} + +#[cfg(test)] +pub(super) fn platform_art_generation_runtime_action_fingerprint_for_test( + state: &PlatformArtGenerationRuntimeState, +) -> &str { + &state.action_fingerprint +} + #[cfg(test)] pub(crate) fn write_platform_art_generation_runtime_accepted_for_test( root: &Path,