diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index 44d7dc7a1..3a1ae2797 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -12,6 +12,10 @@ //! **阶段文案由本模块拥有**(`phase_detail`):前端只渲染后端给的字符串,不自己造百分比或 //! 假阶段。这也是「进度可见」这条验收判据的落点。 //! +//! 账本同时承载**非素材任务**:AI 项目命名(`taskType = project-naming`)与素材任务共用同一份 +//! 记录形状,只是没有素材(`kind` 为 `null`),由前端通过 `enqueue_local_project_naming_task` / +//! `update_local_project_naming_task` 自行推进状态;中断收口一律为 failed,文案不借用素材口径。 +//! //! 账本落在**项目内** `.agent/runtime/asset-generation-tasks/tasks.json`(复用既有 agent runtime //! sidecar 读写原语:临时文件 + rename 替换),所以重开项目后仍能看到历史任务。进程重启时 //! 还在 `queued` / `running` 的记录不可能再有人推进,读账本时按「上次运行中断」收口;收口结论 @@ -27,6 +31,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::agent::{ read_agent_runtime_json_sidecar_with_max_bytes, write_agent_runtime_json_sidecar_with_max_bytes, }; +use crate::commands::validated_local_project_directory_path; use crate::project::{ enforce_project_permission_policy, prepare_local_project_audio_generation, read_existing_manifest_for_project, LocalProjectAudioGenerationRequest, @@ -39,7 +44,13 @@ mod runtime; #[cfg(not(test))] pub(crate) use runtime::start_local_project_asset_generation; -pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1"; +pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v2"; +/// v1 老账本:记录里没有 `taskType`、`kind` 必有值。读取路径必须继续接受(缺省按素材任务处理)。 +pub(crate) const ASSET_GENERATION_TASK_LEGACY_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1"; +/// 任务类型:素材生成(`kind` 必有值)。 +pub(crate) const ASSET_GENERATION_TASK_TYPE_ASSET_GENERATION: &str = "asset-generation"; +/// 任务类型:AI 项目命名(没有素材,`kind` 为 null)。 +pub(crate) const ASSET_GENERATION_TASK_TYPE_PROJECT_NAMING: &str = "project-naming"; pub(crate) const ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH: &str = ".agent/runtime/asset-generation-tasks/tasks.json"; pub(crate) const ASSET_GENERATION_TASK_LEDGER_MAX_BYTES: usize = 1024 * 1024; @@ -48,6 +59,7 @@ pub(crate) const ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS: usize = 50; pub(crate) const ASSET_GENERATION_TASK_ID_MAX_CHARS: usize = 128; pub(crate) const ASSET_GENERATION_TASK_STATUS_QUEUED: &str = "queued"; +pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running"; pub(crate) const ASSET_GENERATION_TASK_STATUS_COMPLETED: &str = "completed"; pub(crate) const ASSET_GENERATION_TASK_STATUS_FAILED: &str = "failed"; pub(crate) const ASSET_GENERATION_TASK_CHANGED_EVENT: &str = @@ -90,13 +102,31 @@ const ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR: &str = "应用退出时生成任务仍在进行,目标素材未登记"; const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str = "应用退出时生成任务仍在进行,未能在清单里确认结果"; +/// 项目命名任务的展示名:命名没有素材条目,但账本记录仍要有一个可读的名字。 +const ASSET_GENERATION_TASK_PROJECT_NAMING_ASSET_NAME: &str = "AI 项目命名"; +/// 中断收口(项目命名):命名没有素材可交叉核对,收口只能是失败;文案必须与素材口径区分。 +const ASSET_GENERATION_TASK_PHASE_NAMING_INTERRUPTED: &str = + "上次运行中断,项目命名未完成(保留兜底名)。"; +const ASSET_GENERATION_TASK_NAMING_INTERRUPTED_ERROR: &str = "应用退出时项目命名任务仍在进行"; + +/// v1 老记录没有 `taskType`:缺省按素材生成任务读回(见 `ASSET_GENERATION_TASK_LEGACY_SCHEMA_VERSION`)。 +fn default_asset_generation_task_type() -> String { + ASSET_GENERATION_TASK_TYPE_ASSET_GENERATION.to_string() +} + /// 一条生成任务的权威记录。字段名与前端一一对应(camelCase)。 #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct AssetGenerationTaskRecord { pub(crate) task_id: String, pub(crate) project_id: String, - pub(crate) kind: GameCreationAppAssetKind, + /// 任务类型:素材生成还是项目命名(见 `ASSET_GENERATION_TASK_TYPE_*`)。 + /// + /// v1 老账本没有这个字段,读回时缺省为 `asset-generation`——这就是「v1 与 v2 都能读」的落点。 + #[serde(default = "default_asset_generation_task_type")] + pub(crate) task_type: String, + /// 素材类型。素材任务必有值;项目命名任务没有素材,为 `None`(JSON `null`)。 + pub(crate) kind: Option, pub(crate) asset_name: String, pub(crate) status: String, /// 阶段文案:**由后端拥有**,前端只渲染。 @@ -118,13 +148,18 @@ pub(crate) struct AssetGenerationTaskRecord { impl AssetGenerationTaskRecord { fn is_terminal(&self) -> bool { - matches!( - self.status.as_str(), - ASSET_GENERATION_TASK_STATUS_COMPLETED | ASSET_GENERATION_TASK_STATUS_FAILED - ) + is_terminal_asset_generation_status(&self.status) } } +/// 终态判据的单一出处:素材与命名任务共用同一组状态值。 +fn is_terminal_asset_generation_status(status: &str) -> bool { + matches!( + status, + ASSET_GENERATION_TASK_STATUS_COMPLETED | ASSET_GENERATION_TASK_STATUS_FAILED + ) +} + #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] struct AssetGenerationTaskLedger { @@ -163,7 +198,21 @@ fn read_ledger(root: &Path) -> Result, String> { "生成任务账本", ASSET_GENERATION_TASK_LEDGER_MAX_BYTES, )?; - Ok(ledger.map(|ledger| ledger.tasks).unwrap_or_default()) + let Some(ledger) = ledger else { + return Ok(Vec::new()); + }; + // 读取路径同时接受 v1(老账本:无 `taskType`、`kind` 必有值)与 v2(当前):v1 记录由 + // `taskType` 的 serde 缺省补齐成素材任务;缺失 `schemaVersion` 也按兼容处理。 + let schema_version = ledger.schema_version.trim(); + if !schema_version.is_empty() + && schema_version != ASSET_GENERATION_TASK_SCHEMA_VERSION + && schema_version != ASSET_GENERATION_TASK_LEGACY_SCHEMA_VERSION + { + return Err(format!( + "生成任务账本 schema 版本不受支持:{schema_version}" + )); + } + Ok(ledger.tasks) } fn write_ledger(root: &Path, tasks: &[AssetGenerationTaskRecord]) -> Result<(), String> { @@ -229,6 +278,9 @@ type RegisteredAssetIdsByLocalPath = Option>; /// - 请求指定了精确落点、且清单里已有该落点 → 按事实收口为**已完成**(带上 assetId); /// - 请求指定了精确落点、清单里没有 → 这次写入确实没落地,收口为失败并说明「目标素材未登记」; /// - 没有精确落点(或清单读不到)→ 收口为失败但**不下结论**,文案是「状态未知(可能已完成)」。 +/// +/// 项目命名任务(`taskType = project-naming`)没有素材可核对,一律收口为 **failed**,文案走命名 +/// 口径(不出现「目标素材」)。 fn repair_interrupted_tasks( tasks: &mut [AssetGenerationTaskRecord], registered: &RegisteredAssetIdsByLocalPath, @@ -242,6 +294,16 @@ fn repair_interrupted_tasks( if task.is_terminal() || live.contains(&task.task_id) { continue; } + // 项目命名任务没有素材可交叉核对(`kind` / `outputPath` 都是空),所以这里不走 manifest + // 口径,直接收口为失败;文案也必须与素材口径区分(不带「目标素材」字样)。 + if task.task_type == ASSET_GENERATION_TASK_TYPE_PROJECT_NAMING { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_NAMING_INTERRUPTED.to_string(); + task.error = Some(ASSET_GENERATION_TASK_NAMING_INTERRUPTED_ERROR.to_string()); + task.finished_at_millis = Some(now_millis()); + repaired = true; + continue; + } let registered_asset = task .output_path .as_deref() @@ -337,7 +399,8 @@ pub(crate) fn begin_local_project_asset_generation_task( let record = AssetGenerationTaskRecord { task_id: task_id.to_string(), project_id: project_id.trim().to_string(), - kind: task_kind, + task_type: ASSET_GENERATION_TASK_TYPE_ASSET_GENERATION.to_string(), + kind: Some(task_kind), asset_name: asset_name.to_string(), status: ASSET_GENERATION_TASK_STATUS_QUEUED.to_string(), phase_detail: ASSET_GENERATION_TASK_PHASE_QUEUED.to_string(), @@ -448,6 +511,159 @@ pub(crate) fn list_local_project_asset_generations( list_local_project_asset_generation_tasks(root) } +/// 入队一条 AI 项目命名任务:与素材任务共用同一份账本,只是 `taskType` / `kind` 不同。 +/// +/// 命名由前端驱动(后台 agent 给出名字后由前端调 `update_local_project_naming_task` 推进), +/// 所以这里只负责落一条 `queued` 记录;推进语义全在 update 命令里。 +/// +/// 幂等:同一个 `task_id` 无论是否终态都**覆盖**(先删后插),账本里不会出现两条同 id 记录。 +/// +/// 在途任务必须登记进 `live_task_ids()`:命名任务同样是「本进程在推」的任务,否则工作台一读账本 +/// (`list_local_project_asset_generations`)就会把这条非终态记录当成上次运行的残留收口成 failed。 +/// 顺序同素材链路——**先登记 live 再落账本**;落账失败只回滚本轮真正插入的那条登记。 +#[tauri::command] +pub(crate) fn enqueue_local_project_naming_task( + project_path: String, + task_id: String, +) -> Result { + let task_id = asset_generation_task_id(&task_id)?; + let root = validated_local_project_directory_path(project_path.trim())?; + // 命名任务的权限判据与既有 `rename_local_game_project` 同口径。 + enforce_project_permission_policy(&root, "project.rename")?; + let project_id = read_existing_manifest_for_project(&root)?.project_id; + let record = AssetGenerationTaskRecord { + task_id: task_id.clone(), + project_id, + task_type: ASSET_GENERATION_TASK_TYPE_PROJECT_NAMING.to_string(), + kind: None, + asset_name: ASSET_GENERATION_TASK_PROJECT_NAMING_ASSET_NAME.to_string(), + status: ASSET_GENERATION_TASK_STATUS_QUEUED.to_string(), + phase_detail: ASSET_GENERATION_TASK_PHASE_QUEUED.to_string(), + output_path: None, + created_at_millis: now_millis(), + started_at_millis: None, + finished_at_millis: None, + asset_id: None, + error: None, + }; + let live_registered = register_live_task_id(&task_id); + let write_result = (|| -> Result<(), String> { + let _guard = lock_ledger()?; + let mut tasks = read_ledger(&root)?; + tasks.retain(|task| task.task_id != record.task_id); + tasks.push(record.clone()); + trim_ledger(&mut tasks); + write_ledger(&root, &tasks) + })(); + if let Err(error) = write_result { + if live_registered { + remove_live_task_id(&task_id); + } + return Err(error); + } + emit_asset_generation_task_changed(&root, &task_id); + Ok(record) +} + +/// 推进一条 AI 项目命名任务。 +/// +/// 语义(命名任务独有的口径,素材任务一律拒绝): +/// - 只作用于 `taskType = project-naming` 的记录,且 `task_id` 必须存在; +/// - 权限门禁与 [`enqueue_local_project_naming_task`] 同口径(`project.rename`),且在任何 +/// 读/改账本之前执行; +/// - 记录的 `project_id` 必须与项目 manifest 的 `project_id` 一致:目录换了项目(或身份被 +/// 改写)时一律返回 Err,不写盘; +/// - `status` 只接受 `queued` / `running` / `completed` / `failed`; +/// - 进入 `running` 时补 `started_at_millis`;进入终态时置 `finished_at_millis`; +/// - **记录一旦终态,就只接受与其当前状态相同的写入(幂等重放)**;传入与当前状态不同的 +/// `status`(改回非终态,或改写成另一种终态)一律返回 Err 且不写盘——命名结果不能被一次 +/// 迟到的排队/失败事件覆盖; +/// - `phase_detail` 传入即覆盖;`error` 只在 `failed` 时保存,非 `failed` 传入会被忽略; +/// - live 集合与状态同步:非终态保持登记(在途任务不能被并发 `list` 收口成失败),终态摘除 +/// (真中断的任务重启后仍能被收口)。落账失败只回滚本轮真正插入的那条登记。 +#[tauri::command] +pub(crate) fn update_local_project_naming_task( + project_path: String, + task_id: String, + status: String, + phase_detail: Option, + error: Option, +) -> Result { + let task_id = asset_generation_task_id(&task_id)?; + let status = naming_task_status(&status)?; + let root = validated_local_project_directory_path(project_path.trim())?; + // 门禁必须在读/改账本之前;与 `enqueue_local_project_naming_task` 用同一个命令位。 + enforce_project_permission_policy(&root, "project.rename")?; + let project_id = read_existing_manifest_for_project(&root)?.project_id; + let _guard = lock_ledger()?; + let mut tasks = read_ledger(&root)?; + let record = tasks + .iter_mut() + .find(|task| task.task_id == task_id) + .ok_or_else(|| format!("生成任务不存在:{task_id}"))?; + if record.task_type != ASSET_GENERATION_TASK_TYPE_PROJECT_NAMING { + return Err(format!("生成任务不是项目命名任务:{task_id}")); + } + // 记录必须属于当前项目:账本是项目内的,但 task_id 仍可能来自另一个项目(或目录被换过)。 + if record.project_id != project_id { + return Err(format!( + "项目命名任务记录不属于该项目:{task_id}(记录 {},当前项目 {project_id})", + record.project_id + )); + } + // 终态一旦落定就是终态:同状态的幂等重放仍允许,任何不同状态都拒绝且不写盘。 + if record.is_terminal() && record.status != status { + return Err(format!( + "项目命名任务已结束,不能再写入不同状态:{task_id}(当前 {},请求 {status})", + record.status + )); + } + record.status = status.to_string(); + if status == ASSET_GENERATION_TASK_STATUS_RUNNING && record.started_at_millis.is_none() { + record.started_at_millis = Some(now_millis()); + } + if is_terminal_asset_generation_status(status) { + record.finished_at_millis = Some(now_millis()); + } + if let Some(phase_detail) = phase_detail { + record.phase_detail = phase_detail; + } + if status == ASSET_GENERATION_TASK_STATUS_FAILED { + if let Some(error) = error { + record.error = Some(error); + } + } + // 非终态:幂等登记 live(在账本锁内做,`list` 不可能插在中间)。终态的摘除放在写盘之后。 + let live_registered = + !is_terminal_asset_generation_status(status) && register_live_task_id(&task_id); + let snapshot = record.clone(); + if let Err(write_error) = write_ledger(&root, &tasks) { + drop(_guard); + if live_registered { + remove_live_task_id(&task_id); + } + return Err(write_error); + } + if is_terminal_asset_generation_status(status) { + remove_live_task_id(&task_id); + } + drop(_guard); + emit_asset_generation_task_changed(&root, &task_id); + Ok(snapshot) +} + +/// 命名任务的状态白名单:与素材任务共用同一组状态常量,其余一律拒绝。 +fn naming_task_status(status: &str) -> Result<&'static str, String> { + let status = status.trim(); + match status { + ASSET_GENERATION_TASK_STATUS_QUEUED => Ok(ASSET_GENERATION_TASK_STATUS_QUEUED), + ASSET_GENERATION_TASK_STATUS_RUNNING => Ok(ASSET_GENERATION_TASK_STATUS_RUNNING), + ASSET_GENERATION_TASK_STATUS_COMPLETED => Ok(ASSET_GENERATION_TASK_STATUS_COMPLETED), + ASSET_GENERATION_TASK_STATUS_FAILED => Ok(ASSET_GENERATION_TASK_STATUS_FAILED), + _ => Err(format!("未知的生成任务状态:{status}")), + } +} + #[cfg(test)] mod asset_generation_task_tests { use super::*; @@ -551,7 +767,7 @@ mod asset_generation_task_tests { assert_eq!(record.status, ASSET_GENERATION_TASK_STATUS_QUEUED); assert_eq!(record.phase_detail, ASSET_GENERATION_TASK_PHASE_QUEUED); assert_eq!(record.project_id, "project-1"); - assert_eq!(record.kind, "image"); + assert_eq!(record.kind.as_deref(), Some("image")); assert_eq!(record.asset_name, "AI 图"); assert!(record.started_at_millis.is_none()); assert!(record.asset_id.is_none()); @@ -894,7 +1110,7 @@ mod asset_generation_task_tests { "9a1b2c3d-4e5f-4a1b-8c2d-3e4f5a6b7c8d", ) .expect("background music task"); - assert_eq!(record.kind, GameCreationAppAssetKind::BackgroundMusic); + assert_eq!(record.kind, Some(GameCreationAppAssetKind::BackgroundMusic)); assert_eq!(record.status, ASSET_GENERATION_TASK_STATUS_QUEUED); assert_eq!(record.phase_detail, ASSET_GENERATION_TASK_PHASE_QUEUED); assert!(record.output_path.is_none()); @@ -912,7 +1128,10 @@ mod asset_generation_task_tests { let listed = list_local_project_asset_generation_tasks(&root).expect("list"); assert_eq!(listed.len(), 1); - assert_eq!(listed[0].kind, GameCreationAppAssetKind::BackgroundMusic); + assert_eq!( + listed[0].kind, + Some(GameCreationAppAssetKind::BackgroundMusic) + ); assert_eq!(listed[0].task_id, record.task_id); // 音效走同一条账本,只是落到另一个 canonical kind。 @@ -926,8 +1145,501 @@ mod asset_generation_task_tests { "2d8b4c9f-3a42-4d7e-8f1b-7c9d1e2f3a45", ) .expect("sound effect task"); - assert_eq!(sound_effect.kind, GameCreationAppAssetKind::SoundEffect); + assert_eq!( + sound_effect.kind, + Some(GameCreationAppAssetKind::SoundEffect) + ); assert_eq!(request.edit_kind, LocalProjectResourceEditKind::SoundEffect); std::fs::remove_dir_all(&root).ok(); } + + fn naming_task_project_path(root: &Path) -> String { + root.to_string_lossy().into_owned() + } + + /// v1 老账本(记录里没有 `taskType`、`kind` 必有值)必须原样读回:`taskType` 缺省为素材任务, + /// 其余字段一个都不能变。 + #[test] + fn legacy_v1_ledger_reads_back_with_the_default_asset_generation_task_type() { + let root = temp_project_root("legacy-v1"); + let ledger_path = root.join(ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH); + std::fs::create_dir_all(ledger_path.parent().expect("ledger parent")).expect("ledger dir"); + std::fs::write( + &ledger_path, + r#"{ + "schemaVersion": "agc-asset-generation-task.v1", + "tasks": [ + { + "taskId": "task-v1", + "projectId": "project-legacy", + "kind": "image", + "assetName": "旧图", + "status": "completed", + "phaseDetail": "生成已完成。", + "outputPath": "assets/legacy.png", + "createdAtMillis": 11, + "startedAtMillis": 12, + "finishedAtMillis": 13, + "assetId": "asset-legacy", + "error": null + } + ] + }"#, + ) + .expect("write legacy ledger"); + + let listed = list_local_project_asset_generation_tasks(&root).expect("list legacy ledger"); + assert_eq!(listed.len(), 1); + let record = &listed[0]; + assert_eq!( + record.task_type, + ASSET_GENERATION_TASK_TYPE_ASSET_GENERATION + ); + assert_eq!(record.kind.as_deref(), Some("image")); + assert_eq!(record.task_id, "task-v1"); + assert_eq!(record.project_id, "project-legacy"); + assert_eq!(record.asset_name, "旧图"); + assert_eq!(record.status, ASSET_GENERATION_TASK_STATUS_COMPLETED); + // 兼容性只保证字段**原样保留**:`phaseDetail` 是 v1 时代后端自己写的字符串,读回必须逐字 + // 不变。它当年恰好等于某个 phase 常量并不构成契约——所以这里对着 fixture 自身的字面量 + // 断言,不引用当前 phase 常量来「证明兼容」。 + assert_eq!(record.phase_detail, "生成已完成。"); + assert_eq!(record.output_path.as_deref(), Some("assets/legacy.png")); + assert_eq!(record.created_at_millis, 11); + assert_eq!(record.started_at_millis, Some(12)); + assert_eq!(record.finished_at_millis, Some(13)); + assert_eq!(record.asset_id.as_deref(), Some("asset-legacy")); + assert!(record.error.is_none()); + std::fs::remove_dir_all(&root).ok(); + } + + /// 不认识的 `schemaVersion` 必须失败关闭:宁可整份读不出来报错,也不能拿旧形状去猜读。 + #[test] + fn ledger_with_an_unsupported_schema_version_fails_closed() { + let root = temp_project_root("schema-unsupported"); + let ledger_path = root.join(ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH); + std::fs::create_dir_all(ledger_path.parent().expect("ledger parent")).expect("ledger dir"); + std::fs::write( + &ledger_path, + r#"{ + "schemaVersion": "agc-asset-generation-task.v3", + "tasks": [] + }"#, + ) + .expect("write unsupported ledger"); + + let error = read_ledger(&root).expect_err("unsupported schema must fail closed"); + assert!(error.contains("schema 版本不受支持"), "{error}"); + std::fs::remove_dir_all(&root).ok(); + } + + /// 缺失 `schemaVersion` 的老账本按 v1 接受:记录必须原样读回(`taskType` 缺省为素材任务)。 + #[test] + fn ledger_without_a_schema_version_is_accepted_as_v1() { + let root = temp_project_root("schema-missing"); + let ledger_path = root.join(ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH); + std::fs::create_dir_all(ledger_path.parent().expect("ledger parent")).expect("ledger dir"); + std::fs::write( + &ledger_path, + r#"{ + "tasks": [ + { + "taskId": "task-no-schema", + "projectId": "project-legacy", + "kind": "image", + "assetName": "无版本号旧图", + "status": "failed", + "phaseDetail": "旧版失败文案。", + "createdAtMillis": 21, + "startedAtMillis": 22, + "finishedAtMillis": 23, + "assetId": null, + "error": "旧版错误" + } + ] + }"#, + ) + .expect("write schema-less ledger"); + + let tasks = read_ledger(&root).expect("missing schemaVersion reads as v1"); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].task_id, "task-no-schema"); + assert_eq!( + tasks[0].task_type, + ASSET_GENERATION_TASK_TYPE_ASSET_GENERATION + ); + assert_eq!(tasks[0].kind.as_deref(), Some("image")); + assert_eq!(tasks[0].created_at_millis, 21); + assert_eq!(tasks[0].finished_at_millis, Some(23)); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn naming_task_enqueue_lands_in_the_shared_ledger_without_an_asset_kind() { + let root = initialized_project_root("naming-enqueue"); + let record = enqueue_local_project_naming_task( + naming_task_project_path(&root), + "naming-1".to_string(), + ) + .expect("enqueue naming task"); + assert_eq!(record.task_type, ASSET_GENERATION_TASK_TYPE_PROJECT_NAMING); + assert!(record.kind.is_none(), "命名任务没有素材类型"); + assert_eq!(record.status, ASSET_GENERATION_TASK_STATUS_QUEUED); + assert_eq!(record.phase_detail, ASSET_GENERATION_TASK_PHASE_QUEUED); + assert_eq!( + record.asset_name, + ASSET_GENERATION_TASK_PROJECT_NAMING_ASSET_NAME + ); + assert_eq!(record.project_id, "project-1"); + assert!(record.created_at_millis > 0); + assert!(record.started_at_millis.is_none()); + assert!(record.finished_at_millis.is_none()); + assert!(record.asset_id.is_none()); + assert!(record.error.is_none()); + + // 在途任务已登记 live:账本里是 queued,共享读命令读到的也必须仍是 queued(不是被 + // 「上次运行中断」收口出来的 failed)。 + let stored = read_ledger(&root).expect("read ledger"); + assert_eq!(stored.len(), 1); + assert_eq!(stored[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + assert!(stored[0].kind.is_none()); + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].task_id, "naming-1"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + remove_live_task_id("naming-1"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn enqueueing_the_same_naming_task_id_twice_keeps_a_single_record() { + let root = initialized_project_root("naming-idempotent"); + let project_path = naming_task_project_path(&root); + enqueue_local_project_naming_task(project_path.clone(), "naming-dup".to_string()) + .expect("first enqueue"); + let second = enqueue_local_project_naming_task(project_path, "naming-dup".to_string()) + .expect("second enqueue"); + assert_eq!(second.status, ASSET_GENERATION_TASK_STATUS_QUEUED); + + // 直接读账本(不经 `list` 的中断收口):同 id 只能有一条。 + let stored = read_ledger(&root).expect("read ledger"); + let matching: Vec<_> = stored + .iter() + .filter(|task| task.task_id == "naming-dup") + .collect(); + assert_eq!(matching.len(), 1, "同一 task id 不得出现两条记录"); + assert_eq!(matching[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + assert_eq!( + matching[0].task_type, + ASSET_GENERATION_TASK_TYPE_PROJECT_NAMING + ); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn naming_task_updates_carry_started_finished_and_error_semantics() { + let root = initialized_project_root("naming-update"); + let project_path = naming_task_project_path(&root); + enqueue_local_project_naming_task(project_path.clone(), "naming-flow".to_string()) + .expect("enqueue"); + + let running = update_local_project_naming_task( + project_path.clone(), + "naming-flow".to_string(), + ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(), + Some("正在生成项目名。".to_string()), + None, + ) + .expect("running"); + assert_eq!(running.status, ASSET_GENERATION_TASK_STATUS_RUNNING); + assert!(running.started_at_millis.is_some()); + assert!(running.finished_at_millis.is_none()); + assert_eq!(running.phase_detail, "正在生成项目名。"); + + let completed = update_local_project_naming_task( + project_path.clone(), + "naming-flow".to_string(), + ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(), + Some("命名已完成。".to_string()), + Some("不该被保存".to_string()), + ) + .expect("completed"); + assert_eq!(completed.status, ASSET_GENERATION_TASK_STATUS_COMPLETED); + assert!(completed.started_at_millis.is_some()); + assert!(completed.finished_at_millis.is_some()); + assert_eq!(completed.phase_detail, "命名已完成。"); + assert!(completed.error.is_none(), "非 failed 的 error 必须被忽略"); + + // 已终态不得改回非终态。 + assert!(update_local_project_naming_task( + project_path.clone(), + "naming-flow".to_string(), + ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(), + None, + None, + ) + .is_err()); + + // failed 才保存 error。 + enqueue_local_project_naming_task(project_path.clone(), "naming-failed".to_string()) + .expect("enqueue failed"); + let failed = update_local_project_naming_task( + project_path, + "naming-failed".to_string(), + ASSET_GENERATION_TASK_STATUS_FAILED.to_string(), + Some("命名失败:超时".to_string()), + Some("命名通道超时".to_string()), + ) + .expect("failed"); + assert_eq!(failed.status, ASSET_GENERATION_TASK_STATUS_FAILED); + assert_eq!(failed.error.as_deref(), Some("命名通道超时")); + assert!(failed.finished_at_millis.is_some()); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn naming_task_update_rejects_unknown_status_and_non_naming_records() { + let root = initialized_project_root("naming-reject"); + let project_path = naming_task_project_path(&root); + enqueue_local_project_naming_task(project_path.clone(), "naming-reject".to_string()) + .expect("enqueue"); + + let error = update_local_project_naming_task( + project_path.clone(), + "naming-reject".to_string(), + "paused".to_string(), + None, + None, + ) + .expect_err("unknown status"); + assert!(error.contains("未知的生成任务状态"), "{error}"); + + // 素材任务不能走命名推进命令。 + begin(&root, "asset-task"); + let error = update_local_project_naming_task( + project_path.clone(), + "asset-task".to_string(), + ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(), + None, + None, + ) + .expect_err("asset record must be rejected"); + assert!(error.contains("不是项目命名任务"), "{error}"); + + // 找不到的 task id 也要失败。 + let error = update_local_project_naming_task( + project_path, + "missing-task".to_string(), + ASSET_GENERATION_TASK_STATUS_QUEUED.to_string(), + None, + None, + ) + .expect_err("missing task"); + assert!(error.contains("生成任务不存在"), "{error}"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn naming_task_update_requires_the_project_rename_permission() { + let root = initialized_project_root("naming-update-permission"); + let project_path = naming_task_project_path(&root); + enqueue_local_project_naming_task(project_path.clone(), "naming-denied".to_string()) + .expect("enqueue"); + + let mut policy = crate::ProjectPermissionPolicy::default(); + policy.denied_commands.push("project.rename".to_string()); + write_project_permission_policy_at(&root, policy).expect("write permission policy"); + + let error = update_local_project_naming_task( + project_path, + "naming-denied".to_string(), + ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(), + None, + None, + ) + .expect_err("denied permission must fail closed"); + assert!( + error.contains("项目权限策略拒绝执行:project.rename"), + "{error}" + ); + + // 门禁在读写账本之前:账本必须原封不动。 + let stored = read_ledger(&root).expect("read ledger"); + assert_eq!(stored[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + remove_live_task_id("naming-denied"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn naming_task_update_rejects_a_record_from_another_project() { + let root = initialized_project_root("naming-foreign-record"); + let project_path = naming_task_project_path(&root); + enqueue_local_project_naming_task(project_path.clone(), "naming-foreign".to_string()) + .expect("enqueue"); + + // 账本记录的项目身份被改成另一个项目(目录被换过 / task id 来自别的项目)。 + let mut stored = read_ledger(&root).expect("read ledger"); + stored[0].project_id = "another-project".to_string(); + write_ledger(&root, &stored).expect("write ledger"); + + let error = update_local_project_naming_task( + project_path, + "naming-foreign".to_string(), + ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(), + None, + None, + ) + .expect_err("foreign record must fail closed"); + assert!(error.contains("记录不属于该项目"), "{error}"); + + // 拒绝发生在改动之前:状态仍是 queued。 + let stored = read_ledger(&root).expect("read ledger again"); + assert_eq!(stored[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + remove_live_task_id("naming-foreign"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn naming_task_terminal_state_rejects_a_different_status_but_allows_the_same_one() { + let root = initialized_project_root("naming-terminal"); + let project_path = naming_task_project_path(&root); + enqueue_local_project_naming_task(project_path.clone(), "naming-terminal".to_string()) + .expect("enqueue"); + update_local_project_naming_task( + project_path.clone(), + "naming-terminal".to_string(), + ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(), + Some("命名已完成。".to_string()), + None, + ) + .expect("completed"); + + // 终态 → 另一种终态:必须拒绝且不写盘(旧实现会静默把 completed 改写成 failed)。 + let error = update_local_project_naming_task( + project_path.clone(), + "naming-terminal".to_string(), + ASSET_GENERATION_TASK_STATUS_FAILED.to_string(), + Some("迟到的失败。".to_string()), + Some("迟到的失败".to_string()), + ) + .expect_err("terminal state must not be overwritten"); + assert!(error.contains("不能再写入不同状态"), "{error}"); + let stored = read_ledger(&root).expect("read ledger"); + assert_eq!(stored[0].status, ASSET_GENERATION_TASK_STATUS_COMPLETED); + assert_eq!(stored[0].phase_detail, "命名已完成。"); + assert!(stored[0].error.is_none()); + + // 同状态幂等重放仍允许。 + let replayed = update_local_project_naming_task( + project_path, + "naming-terminal".to_string(), + ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(), + None, + None, + ) + .expect("same-state replay is allowed"); + assert_eq!(replayed.status, ASSET_GENERATION_TASK_STATUS_COMPLETED); + assert_eq!(replayed.phase_detail, "命名已完成。"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn interrupted_naming_task_is_settled_as_failed_with_naming_wording() { + let root = initialized_project_root("naming-interrupted"); + let project_path = naming_task_project_path(&root); + enqueue_local_project_naming_task( + project_path.clone(), + "naming-interrupted-task".to_string(), + ) + .expect("enqueue"); + update_local_project_naming_task( + project_path, + "naming-interrupted-task".to_string(), + ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(), + Some("正在生成项目名。".to_string()), + None, + ) + .expect("running"); + + // 模拟「上次运行留下的残留」:进程重启后 live 集合为空,所以这里先摘掉在途登记。 + remove_live_task_id("naming-interrupted-task"); + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed.len(), 1); + let record = &listed[0]; + assert_eq!(record.status, ASSET_GENERATION_TASK_STATUS_FAILED); + assert_eq!( + record.phase_detail, + ASSET_GENERATION_TASK_PHASE_NAMING_INTERRUPTED + ); + assert!( + !record.phase_detail.contains("目标素材"), + "命名任务不得使用素材口径:{}", + record.phase_detail + ); + assert_eq!( + record.error.as_deref(), + Some(ASSET_GENERATION_TASK_NAMING_INTERRUPTED_ERROR) + ); + assert!(record.finished_at_millis.is_some()); + + // 收口结论写回账本,第二次读到仍是同一条终态记录。 + let again = list_local_project_asset_generation_tasks(&root).expect("list again"); + assert_eq!(again[0].status, ASSET_GENERATION_TASK_STATUS_FAILED); + std::fs::remove_dir_all(&root).ok(); + } + + /// 在途的命名任务不能被账本读取误杀:只有终态才从 live 集合摘除。 + #[test] + fn naming_task_stays_in_flight_across_ledger_reads_until_terminal() { + let root = initialized_project_root("naming-in-flight"); + let project_path = naming_task_project_path(&root); + let task_id = "naming-in-flight-task"; + + enqueue_local_project_naming_task(project_path.clone(), task_id.to_string()) + .expect("enqueue"); + assert!( + live_task_ids().lock().expect("live ids").contains(task_id), + "enqueue 必须把在途命名任务登记进 live 集合" + ); + let queued = list_local_project_asset_generation_tasks(&root).expect("list queued"); + assert_eq!(queued[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + + update_local_project_naming_task( + project_path.clone(), + task_id.to_string(), + ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(), + Some("正在生成项目名。".to_string()), + None, + ) + .expect("running"); + let running = list_local_project_asset_generation_tasks(&root).expect("list running"); + assert_eq!(running[0].status, ASSET_GENERATION_TASK_STATUS_RUNNING); + + update_local_project_naming_task( + project_path, + task_id.to_string(), + ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(), + Some("命名已完成。".to_string()), + None, + ) + .expect("completed"); + let completed = list_local_project_asset_generation_tasks(&root).expect("list completed"); + assert_eq!(completed[0].status, ASSET_GENERATION_TASK_STATUS_COMPLETED); + assert!( + !live_task_ids().lock().expect("live ids").contains(task_id), + "终态必须从 live 集合摘除" + ); + + // 终态后 live 已摘除:把账本记录改回非终态(模拟进程重启后的残留),读取必须收口 failed。 + let mut stored = read_ledger(&root).expect("read ledger"); + stored[0].status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); + write_ledger(&root, &stored).expect("write ledger"); + let settled = list_local_project_asset_generation_tasks(&root).expect("list settled"); + assert_eq!(settled[0].status, ASSET_GENERATION_TASK_STATUS_FAILED); + assert_eq!( + settled[0].phase_detail, + ASSET_GENERATION_TASK_PHASE_NAMING_INTERRUPTED + ); + std::fs::remove_dir_all(&root).ok(); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks/runtime.rs index 4a3ccad0d..87a925647 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks/runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks/runtime.rs @@ -5,7 +5,6 @@ use crate::agent::{ use crate::commands::prepare_local_project_asset_generation; use crate::project::run_local_project_audio_generation_at; -pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running"; const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。"; /// 音频任务收口:通道跑完但没有登记出素材(`derive` 在有源 / 无源两条路上都必须登记 assets)。 const ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR: &str = "生成完成但未登记素材"; 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 9bcdfd7ee..09c9c6400 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -502,6 +502,297 @@ pub(crate) fn rename_local_game_project( }) } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ConditionalRenameLocalProjectResult { + pub(crate) renamed: bool, + pub(crate) manifest: GameCreationAppManifest, + pub(crate) revision: u64, +} + +/// AI 后台命名回填用的条件改名核心。 +/// +/// 只有「项目身份未变」且「当前名称仍是这次创建时的兜底名」时才落盘改名;任何一项不匹配都 +/// 原样返回当前 manifest / revision 并把 `renamed` 置为 false,绝不覆盖用户已经改过的名字。 +/// 与 [`rename_local_game_project`] 一致:改名是簿记写入,刻意不推项目 revision。 +pub(crate) fn rename_local_game_project_if_unchanged_at( + root: &Path, + expected_project_id: &str, + expected_name: &str, + name: &str, +) -> Result { + let next_name = normalize_game_creation_project_name(name.trim())?; + let expected_project_id = expected_project_id.trim(); + if expected_project_id.is_empty() { + return Err("项目改名 expectedProjectId 不能为空".to_string()); + } + let expected_name = expected_name.trim(); + if expected_name.is_empty() { + return Err("项目改名 expectedName 不能为空".to_string()); + } + enforce_project_permission_policy(root, "project.rename")?; + let _lock = acquire_project_write_lock(root, "project.rename")?; + let current = read_existing_manifest_for_project(root)?; + let revision = read_game_creator_agent_runtime_project_revision(root)?.revision; + // 项目身份不匹配:目录换了项目或身份被改写,一律不改名。 + if current.project_id != expected_project_id { + return Ok(ConditionalRenameLocalProjectResult { + renamed: false, + manifest: current, + revision, + }); + } + // 用户已经手动改过名(或别的流程改过):兜底名不再成立,绝不覆盖。 + if current.name != expected_name { + return Ok(ConditionalRenameLocalProjectResult { + renamed: false, + manifest: current, + revision, + }); + } + // 结果与现状一致:没有必要再写一次盘。 + if current.name == next_name { + return Ok(ConditionalRenameLocalProjectResult { + renamed: false, + manifest: current, + revision, + }); + } + + let manifest = mutate_manifest_at(root, |manifest| { + manifest.name = next_name; + Ok(manifest.clone()) + })?; + Ok(ConditionalRenameLocalProjectResult { + renamed: true, + manifest, + revision: read_game_creator_agent_runtime_project_revision(root)?.revision, + }) +} + +#[tauri::command] +pub(crate) fn rename_local_game_project_if_unchanged( + project_path: String, + expected_project_id: String, + expected_name: String, + name: String, +) -> Result { + let root = validated_local_project_directory_path(project_path.trim())?; + rename_local_game_project_if_unchanged_at(&root, &expected_project_id, &expected_name, &name) +} + +#[cfg(test)] +mod conditional_project_rename_tests { + use super::*; + + const FALLBACK_NAME: &str = "策划项目 ab12cd34"; + + fn fixture_root(project_id: &str) -> tempfile::TempDir { + let project = tempfile::tempdir().expect("project tempdir"); + init_local_game_project_at(project.path(), project_id, FALLBACK_NAME) + .expect("initialize project"); + project + } + + fn manifest_name(root: &Path) -> String { + read_existing_manifest_for_project(root) + .expect("read manifest") + .name + } + + fn project_revision(root: &Path) -> u64 { + read_game_creator_agent_runtime_project_revision(root) + .expect("read revision") + .revision + } + + #[test] + fn conditional_rename_applies_when_fallback_name_still_matches() { + let project = fixture_root("conditional-rename-project"); + let root = project.path(); + let project_id = read_existing_manifest_for_project(root) + .expect("read manifest before") + .project_id; + let revision_before = project_revision(root); + + let result = rename_local_game_project_if_unchanged_at( + root, + "conditional-rename-project", + FALLBACK_NAME, + "星轨夜航", + ) + .expect("conditional rename applies"); + + assert!(result.renamed); + assert_eq!(result.manifest.name, "星轨夜航"); + assert_eq!(result.manifest.project_id, project_id); + assert_eq!(manifest_name(root), "星轨夜航"); + assert_eq!(project_revision(root), result.revision); + // 与 rename_local_game_project 同语义:改名是簿记写入,刻意不推项目 revision + // (见 projectResourceLiveUpdateModel.ts:推 revision 会让运行时的验证凭证无故漂移), + // 所以这里断言改名前后 revision 一致,而不是变大。 + assert_eq!(result.revision, revision_before); + } + + #[test] + fn conditional_rename_keeps_user_rename_when_expected_name_is_stale() { + let project = fixture_root("conditional-rename-user-edited"); + let root = project.path(); + // 用户先手动改名:此刻兜底名仍然成立,这一步会落盘。 + let edited = rename_local_game_project_if_unchanged_at( + root, + "conditional-rename-user-edited", + FALLBACK_NAME, + "用户自己起的名字", + ) + .expect("user rename applies"); + assert!(edited.renamed); + + // AI 后台命名这时才回来:它带的兜底名已过期,必须整体跳过。 + let result = rename_local_game_project_if_unchanged_at( + root, + "conditional-rename-user-edited", + FALLBACK_NAME, + "星轨夜航", + ) + .expect("stale expected name is not an error"); + + assert!(!result.renamed); + assert_eq!(result.manifest.name, "用户自己起的名字"); + assert_eq!(manifest_name(root), "用户自己起的名字"); + } + + #[test] + fn conditional_rename_skips_when_project_identity_differs() { + let project = fixture_root("conditional-rename-project"); + let root = project.path(); + let revision_before = project_revision(root); + + let result = rename_local_game_project_if_unchanged_at( + root, + "another-project-id", + FALLBACK_NAME, + "星轨夜航", + ) + .expect("identity mismatch is not an error"); + + assert!(!result.renamed); + assert_eq!(result.manifest.project_id, "conditional-rename-project"); + assert_eq!(result.manifest.name, FALLBACK_NAME); + assert_eq!(manifest_name(root), FALLBACK_NAME); + assert_eq!(project_revision(root), revision_before); + } + + #[test] + fn conditional_rename_rejects_invalid_name_without_touching_the_project() { + let project = fixture_root("conditional-rename-invalid"); + let root = project.path(); + let revision_before = project_revision(root); + + for invalid in ["", " ", "带\u{0}控制字符的名"] { + let error = rename_local_game_project_if_unchanged_at( + root, + "conditional-rename-invalid", + FALLBACK_NAME, + invalid, + ) + .expect_err("invalid name must fail closed"); + assert!(error.contains("项目名称"), "{error}"); + } + + assert_eq!(manifest_name(root), FALLBACK_NAME); + assert_eq!(project_revision(root), revision_before); + } + + /// 超长名字同样走 `normalize_game_creation_project_name` 的门禁:条件改名必须失败关闭, + /// 且盘上 manifest 与 revision 都不动。 + #[test] + fn conditional_rename_rejects_an_over_long_name_without_touching_the_project() { + let project = fixture_root("conditional-rename-too-long"); + let root = project.path(); + let revision_before = project_revision(root); + + let too_long = "名".repeat(crate::project::GAME_CREATION_PROJECT_NAME_MAX_CHARS + 1); + let error = rename_local_game_project_if_unchanged_at( + root, + "conditional-rename-too-long", + FALLBACK_NAME, + &too_long, + ) + .expect_err("over-long name must fail closed"); + assert!(error.contains("项目名称"), "{error}"); + + assert_eq!(manifest_name(root), FALLBACK_NAME); + assert_eq!(project_revision(root), revision_before); + } + + #[test] + fn conditional_rename_rejects_empty_expectations_without_touching_the_project() { + let project = fixture_root("conditional-rename-empty-expectation"); + let root = project.path(); + let revision_before = project_revision(root); + + // 空 expectedProjectId / expectedName 与其它按 expected 判等的入口同口径:失败关闭, + // 而不是静默地按「不匹配」跳过——空串永远不可能是调用方真正想表达的身份。 + for (expected_project_id, expected_name, marker) in [ + ("", FALLBACK_NAME, "expectedProjectId"), + (" ", FALLBACK_NAME, "expectedProjectId"), + ("conditional-rename-empty-expectation", "", "expectedName"), + ( + "conditional-rename-empty-expectation", + " ", + "expectedName", + ), + ] { + let error = rename_local_game_project_if_unchanged_at( + root, + expected_project_id, + expected_name, + "星轨夜航", + ) + .expect_err("empty expectation must fail closed"); + assert!(error.contains(marker), "{error} 应指出 {marker}"); + } + + assert_eq!(manifest_name(root), FALLBACK_NAME); + assert_eq!(project_revision(root), revision_before); + + // 非空但不匹配仍然是「跳过」这条正常路径:用户已改名不是错误。 + let skipped = rename_local_game_project_if_unchanged_at( + root, + "conditional-rename-empty-expectation", + "用户自己起的名字", + "星轨夜航", + ) + .expect("stale but non-empty expectation is not an error"); + assert!(!skipped.renamed); + assert_eq!(skipped.manifest.name, FALLBACK_NAME); + assert_eq!(manifest_name(root), FALLBACK_NAME); + } + + #[test] + fn conditional_rename_reports_false_when_name_already_matches() { + let project = fixture_root("conditional-rename-same-name"); + let root = project.path(); + let revision_before = project_revision(root); + + for same in [FALLBACK_NAME, " 策划项目 ab12cd34 "] { + let result = rename_local_game_project_if_unchanged_at( + root, + "conditional-rename-same-name", + FALLBACK_NAME, + same, + ) + .expect("same name is not an error"); + assert!(!result.renamed); + assert_eq!(result.manifest.name, FALLBACK_NAME); + } + + assert_eq!(manifest_name(root), FALLBACK_NAME); + assert_eq!(project_revision(root), revision_before); + } +} + #[tauri::command] pub(crate) fn import_local_godot_project( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs index 1eec25092..8f80ac651 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -550,6 +550,7 @@ pub(super) fn run() { inspect_local_project_directory, pick_local_project_directory, rename_local_game_project, + rename_local_game_project_if_unchanged, suggest_automatic_project_name, polish_local_project_prompt, pick_client_extension_file, @@ -652,6 +653,8 @@ pub(super) fn run() { generate_local_project_asset, start_local_project_asset_generation, list_local_project_asset_generations, + enqueue_local_project_naming_task, + update_local_project_naming_task, open_canvas_project, run_limited_local_command, append_local_permission_log, diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index dfadbe616..3fc145c7f 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -177,6 +177,16 @@ export interface RenameLocalProjectResult { revision: number; } +/** + * 条件改名结果:`renamed === false` 表示宿主按「项目 ID 或当前名称不匹配」整体跳过, + * 此时 `manifest` / `revision` 仍是跳过时的现状,调用方不要据此覆盖本地上下文。 + */ +export interface ConditionalRenameLocalProjectResult { + renamed: boolean; + manifest: GameCreationAppManifest; + revision: number; +} + export interface LocalProjectDirectoryStatus { projectPath: string; exists: boolean; diff --git a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx index 19d87059d..6801e0212 100644 --- a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx +++ b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx @@ -177,6 +177,7 @@ export function WindowChrome({ children }: WindowChromeProps) { void; }; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx index 1df87f2bb..9d17d308b 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx @@ -6,15 +6,18 @@ import { projectNameFromPath } from '../agent-runtime'; import { projectPathsMatchForInvalidation } from '../project-summary/projectPath'; /** - * 窗口标题栏的"正在运行的项目"入口,也保留面板布局供独立组件测试和复用。 + * 窗口标题栏的项目入口,也保留面板布局供独立组件测试和复用。 * - * 数据来自 Rust 的活动回合注册表(同一个只读快照也用于重新进入项目时的进度重连), - * 面板只负责呈现:项目名、阶段、已运行时长,以及点击进入该项目。没有在跑回合时 - * 整块不渲染,不留空白占位。 + * 数据来自 Rust 的活动回合注册表(同一个只读快照也用于重新进入项目时的进度重连)。 + * 标题栏形态的主文案恒为**当前项目**:正在运行的项目只占圆点、数量徽标与展开菜单, + * 否则「我在哪个项目」会被后台别的项目顶掉;不在项目内(没有 `currentProjectName`)时 + * 才回落到最近启动的运行项目。没有在跑回合且没读失败时整块不渲染,不留空白占位。 */ export type ActiveProjectRunsPanelProps = { activeTurns: GameCreatorDirectActiveTurn[]; currentProjectPath?: string | null; + /** 当前工作台打开的项目名;有值即顶栏主文案,运行中的项目不再抢它。 */ + currentProjectName?: string | null; readFailed?: boolean; onOpenProject?: (projectPath: string) => void; placement?: 'panel' | 'titlebar'; @@ -58,6 +61,7 @@ function activeTurnDisplayName(turn: GameCreatorDirectActiveTurn) { export function ActiveProjectRunsPanel({ activeTurns, currentProjectPath = null, + currentProjectName = null, readFailed = false, onOpenProject, placement = 'panel', @@ -87,11 +91,26 @@ export function ActiveProjectRunsPanel({ }; }, [open, placement]); - if (activeTurns.length === 0) { - if (!readFailed) { + const now = Date.now(); + const orderedTurns = [...activeTurns].sort( + (left, right) => left.startedAt - right.startedAt, + ); + const latestTurn = orderedTurns[orderedTurns.length - 1] ?? null; + const runningCount = orderedTurns.length; + const currentName = currentProjectName?.trim() ?? ''; + /** + * 标签主文案只认当前项目:正在运行的项目不在标题栏抢主文案,只在徽标和展开菜单里表达。 + * 不在项目内(没有当前项目名)时才回落到最近启动的运行项目。 + */ + const primaryName = + currentName || (latestTurn ? activeTurnDisplayName(latestTurn) : ''); + + if (placement === 'titlebar') { + if (!latestTurn && !readFailed) { return null; } - if (placement === 'titlebar') { + // 读不到快照、又没有当前项目可显示:保持原来那条只读提示,不假装成可展开入口。 + if (!primaryName) { return ( ); } - // 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。 - return ( - - ); - } - - const now = Date.now(); - const orderedTurns = [...activeTurns].sort( - (left, right) => left.startedAt - right.startedAt, - ); - if (placement === 'titlebar') { - const latestTurn = orderedTurns[orderedTurns.length - 1]; - if (!latestTurn) { - return null; - } - const latestName = activeTurnDisplayName(latestTurn); + /** + * 标签提示 = 作用域 + 运行状态,分成两段是为了不在一句话里重复「正在运行」: + * 回落态的主文案本身就是「最近启动的在跑项目」,状态位再写一遍会读成「正在运行的项目:X; + * 正在运行 N 个项目」。 + */ + const scopeLabel = currentName + ? `当前项目:${primaryName}` + : `最近运行的项目:${primaryName}`; + const runStateLabel = readFailed + ? '正在运行的项目读取失败' + : `共 ${runningCount} 个项目在运行`; + const triggerLabel = `${scopeLabel};${runStateLabel}`; const openProject = (projectPath: string) => { setOpen(false); onOpenProject?.(projectPath); @@ -129,23 +140,30 @@ export function ActiveProjectRunsPanel({
- - ); - })} - + + {name} + + + {[ + isCurrent ? '当前' : null, + activeTurnStatusLabel(turn.status), + elapsed, + ] + .filter(Boolean) + .join(' · ')} + + + + ); + })} + + ) : null}
) : null} ); } + if (runningCount === 0) { + // 快照读到了、只是没有在跑回合:整块不渲染,不能报成读取失败。 + if (!readFailed) { + return null; + } + // 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。 + return ( + + ); + } + return (