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 2831a0cd7..0deaf9595 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -866,6 +866,19 @@ pub(crate) fn delete_local_project_asset( ) } +/// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。 +/// +/// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败 +/// 时把文件改回原名,不留半成品。 +#[tauri::command] +pub(crate) fn rename_local_project_asset( + input: RenameLocalProjectAssetInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + rename_local_project_asset_at(root, &input.asset_id, &input.new_file_name, None) +} + #[tauri::command] pub(crate) async fn control_agent_run( app: tauri::AppHandle, 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 7a8b93001..fec02b8c5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2686,6 +2686,7 @@ fn main() { read_local_project_resource_graph, update_local_project_resource_canvas_layout, delete_local_project_asset, + rename_local_project_asset, get_local_game_project_revision, get_local_game_manifest, download_agc_update, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 7baff5dbe..564ad9872 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -4,6 +4,7 @@ use similar::TextDiff; use std::io::{Seek, SeekFrom}; mod agent_db; +mod asset_rename; mod asset_usage; mod checkpoint; mod conversation; @@ -18,6 +19,7 @@ mod resource_layout; mod verification; pub(crate) use agent_db::*; +pub(crate) use asset_rename::*; pub(crate) use asset_usage::*; pub(crate) use checkpoint::*; pub(crate) use conversation::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs new file mode 100644 index 000000000..ecf2b74e5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs @@ -0,0 +1,256 @@ +use super::*; + +use super::filesystem::validate_portable_project_path_component; + +/// 素材重命名的入参。 +/// +/// 只收"新文件名"而不是新旧两个全路径:改名被限制在资产当前所在目录内,目录由 manifest +/// 里的 `localPath` 决定,调用方无法指定目标目录。 +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct RenameLocalProjectAssetInput { + pub(crate) project_path: String, + pub(crate) asset_id: String, + pub(crate) new_file_name: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RenameLocalProjectAssetResult { + pub(crate) asset: GameCreationAppAssetManifestEntry, + pub(crate) previous_local_path: String, + pub(crate) committed_project_revision: u64, +} + +/// 故障注入点:让测试在不依赖只读路径的前提下验证"manifest 写失败必须回滚文件改名"。 +/// 生产路径始终传 `None`。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RenameLocalProjectAssetFaultStage { + /// 文件已改名、manifest 尚未写入时失败。 + ManifestWrite, +} + +/// 校验调用方给出的新文件名,返回 trim 后的名字。 +/// +/// 判据(按顺序): +/// - 非空; +/// - 不含路径分隔符(`/`、`\`)——这同时保证"不跨目录",改名只落在资产当前所在目录内; +/// - 不含 `..`——分隔符已被拒绝后,`..` 仍可能以"整个名字就是 `..`"的形式改变层级,一律拒绝; +/// - 通过项目路径组件的可移植性校验(控制字符、Windows 保留字符、结尾点或空格、保留设备名), +/// 与项目内其它写入路径保持同一套路径安全口径。 +fn normalize_asset_file_name(value: &str) -> Result { + let name = value.trim(); + if name.is_empty() { + return Err("新文件名不能为空".to_string()); + } + if name.contains('/') || name.contains('\\') { + return Err("新文件名不能包含路径分隔符".to_string()); + } + if name.contains("..") { + return Err("新文件名不能包含 ..".to_string()); + } + validate_portable_project_path_component(name) + .map_err(|error| format!("新文件名非法:{error}"))?; + Ok(name.to_string()) +} + +/// 取小写扩展名。`.gitignore` 这类隐藏文件按"无扩展名"处理。 +fn asset_file_extension(file_name: &str) -> Option { + Path::new(file_name) + .extension() + .and_then(std::ffi::OsStr::to_str) + .map(str::to_ascii_lowercase) +} + +/// 一个帧的 `imageSrc` 是否就是被改名的那份本地文件。 +/// +/// 判据:既不是绝对路径、也不是带 scheme 的远程地址、也不是反斜杠路径;去掉目录后文件名与旧名 +/// 一致(大小写不敏感,和扩展名比较同口径)。只认真正指向该文件的帧,同目录但指向其它文件的帧 +/// 保持原样,避免改名连带改坏别的引用。 +fn asset_local_frame_matches(image_src: &str, directory: &str, previous_file_name: &str) -> bool { + let image_src = image_src.trim(); + if image_src.is_empty() + || image_src.starts_with('/') + || image_src.contains('\\') + || image_src.contains("://") + || Path::new(image_src).is_absolute() + { + return false; + } + let (frame_directory, frame_file_name) = match image_src.rsplit_once('/') { + Some((directory, file_name)) => (directory, file_name), + None => ("", image_src), + }; + frame_directory == directory && frame_file_name.eq_ignore_ascii_case(previous_file_name) +} + +/// 把 manifest 条目里指向被改名文件的序列帧对齐到新 `localPath`。 +fn align_image_sequence_frames( + asset: &mut GameCreationAppAssetManifestEntry, + directory: &str, + previous_file_name: &str, + next_local_path: &str, +) { + let Some(frames) = asset.image_sequence_frames.as_mut() else { + return; + }; + for frame in frames.iter_mut() { + if !asset_local_frame_matches(&frame.image_src, directory, previous_file_name) { + continue; + } + frame.image_src = next_local_path.to_string(); + } +} + +/// manifest 写失败后的回滚:把文件改回原名,并把回滚失败单独报出来。 +fn rollback_asset_file_rename( + current_absolute: &Path, + next_absolute: &Path, + error: String, +) -> String { + match fs::rename(next_absolute, current_absolute) { + Ok(()) => error, + Err(rollback_error) => format!( + "{error};reconciliation-required: 素材文件未能改回原名:{} -> {}: {rollback_error}", + next_absolute.display(), + current_absolute.display() + ), + } +} + +/// 重命名一个已登记素材:**文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变**。 +/// +/// manifest 资产条目没有 `name` 字段,资源显示名来自 `fileName(localPath)`,因此重命名的语义 +/// 只能是"改文件名 + 改 `localPath`"。 +/// +/// 事务顺序(全程持既有项目写锁,期间不会出现第二个项目写者;manifest 写入仍走既有边界: +/// 版本数组不可变校验 + 安装后回读一致性校验): +/// 1. 读 manifest 定位资产,取旧 `localPath`; +/// 2. 校验新文件名、扩展名一致、目标不冲突,然后 `rename` 磁盘文件; +/// 3. 更新 manifest 的 `localPath`(`id` / `kind` / `mediaType` / `source` / `category` / `tags` +/// 全部不变),并把 `imageSequenceFrames` 里指向该文件的帧对齐到新路径; +/// 4. 写 manifest; +/// 5. 第 4 步失败时把文件改回原名,不留"文件已改名但 manifest 还是旧路径"的半成品; +/// 改回原名也失败时,两个错误都报出来并标记 reconciliation-required。 +pub(crate) fn rename_local_project_asset_at( + root: &Path, + asset_id: &str, + new_file_name: &str, + fault: Option, +) -> Result { + let asset_id = asset_id.trim(); + if asset_id.is_empty() { + return Err("素材重命名 assetId 不能为空".to_string()); + } + let new_file_name = normalize_asset_file_name(new_file_name)?; + + let _lock = acquire_project_write_lock(root, "asset.register")?; + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = read_existing_manifest_for_project(root)?; + let index = manifest + .assets + .iter() + .position(|asset| asset.id == asset_id) + .ok_or_else(|| format!("项目资源不存在:{asset_id}"))?; + let previous_local_path = manifest.assets[index].local_path.clone(); + let (directory, current_file_name) = match previous_local_path.rsplit_once('/') { + Some((directory, file_name)) => (directory.to_string(), file_name.to_string()), + None => (String::new(), previous_local_path.clone()), + }; + + // 同名重命名是空操作:磁盘与 manifest 都不动,直接回报当前状态。 + if current_file_name == new_file_name { + return Ok(RenameLocalProjectAssetResult { + asset: manifest.assets[index].clone(), + previous_local_path, + committed_project_revision: read_game_creator_agent_runtime_project_revision(root)? + .revision, + }); + } + + let current_extension = asset_file_extension(¤t_file_name); + let next_extension = asset_file_extension(&new_file_name); + if current_extension != next_extension { + return Err(format!( + "新文件名扩展名必须与原文件一致,原扩展名:{}", + current_extension + .map(|extension| format!(".{extension}")) + .unwrap_or_else(|| "无".to_string()) + )); + } + + let next_local_path = normalize_relative_path(&if directory.is_empty() { + new_file_name.clone() + } else { + format!("{directory}/{new_file_name}") + })?; + let current_absolute = resolve_local_project_path(root, &previous_local_path)?; + let next_absolute = resolve_local_project_path(root, &next_local_path)?; + + // 被改名的必须是真的普通文件:登记与磁盘不一致时先报错,不写出指向"新名字"的悬空登记。 + match fs::symlink_metadata(¤t_absolute) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {} + Ok(_) => return Err(format!("素材路径必须是普通文件:{previous_local_path}")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(format!("素材文件不存在:{previous_local_path}")); + } + Err(error) => { + return Err(format!( + "读取素材元数据失败:{}: {error}", + current_absolute.display() + )); + } + } + // 同目录内不得已存在同名文件:先检查再改名,避免 `rename` 覆盖既有文件。 + // 只差文件名大小写的改名在大小写不敏感的文件系统上会命中同一条检查,按"同名冲突"拒绝。 + match fs::symlink_metadata(&next_absolute) { + Ok(_) => return Err(format!("同目录已存在同名文件:{new_file_name}")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取目标素材元数据失败:{}: {error}", + next_absolute.display() + )); + } + } + + fs::rename(¤t_absolute, &next_absolute).map_err(|error| { + format!( + "素材文件改名失败:{} -> {}: {error}", + current_absolute.display(), + next_absolute.display() + ) + })?; + + manifest.assets[index].local_path = next_local_path.clone(); + align_image_sequence_frames( + &mut manifest.assets[index], + &directory, + ¤t_file_name, + &next_local_path, + ); + let asset = manifest.assets[index].clone(); + + // 文件已改名:从这里开始的任何失败都必须把文件改回原名。 + let write_error = match fault { + Some(RenameLocalProjectAssetFaultStage::ManifestWrite) => { + Some("fault-injected:rename-asset-manifest-write".to_string()) + } + None => write_manifest(&manifest_path, &manifest).err(), + }; + if let Some(error) = write_error { + return Err(rollback_asset_file_rename( + ¤t_absolute, + &next_absolute, + error, + )); + } + + let committed_project_revision = advance_agent_runtime_project_revision_locked(root) + .map_err(|error| format!("素材已改名,但项目 revision 未能推进:{error}"))?; + Ok(RenameLocalProjectAssetResult { + asset, + previous_local_path, + committed_project_revision, + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs new file mode 100644 index 000000000..4709cc90c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs @@ -0,0 +1,324 @@ +use super::*; +use shared_contracts::game_creation_app::GameCreationAppImageSequenceFrame; + +/// 一个已初始化项目 + 一份已登记的 `assets/hero.png`。 +fn asset_rename_project_fixture() -> (PathBuf, String) { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + write_local_project_file_at(&root, "assets/hero.png", "hero-bytes").expect("asset file"); + let asset = register_local_asset_at( + &root, + "assets/hero.png", + "character", + "image/png", + "canvas", + asset_rename_source(), + ) + .expect("asset register"); + (root, asset.id) +} + +fn asset_rename_source() -> GameCreationAppAssetSource { + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("canvas-project-1".to_string()), + resource_id: Some("resource-1".to_string()), + asset_object_id: Some("asset-object-1".to_string()), + task_id: Some("task-1".to_string()), + prompt: Some("像素主角".to_string()), + model: Some("image-model".to_string()), + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + } +} + +fn asset_rename_entry(root: &Path, asset_id: &str) -> GameCreationAppAssetManifestEntry { + read_existing_manifest_for_project(root) + .expect("read manifest") + .assets + .into_iter() + .find(|asset| asset.id == asset_id) + .expect("asset entry") +} + +fn asset_rename_revision(root: &Path) -> u64 { + read_game_creator_agent_runtime_project_revision(root) + .expect("read project revision") + .revision +} + +/// 成功改名:磁盘文件真的换了名字,manifest 只改 `localPath`,资产 `id` 与其余字段不变, +/// 项目 revision 前进一格。 +#[test] +fn rename_local_project_asset_renames_file_and_keeps_asset_identity() { + let (root, asset_id) = asset_rename_project_fixture(); + let before = asset_rename_entry(&root, &asset_id); + let revision_before = asset_rename_revision(&root); + + let result = rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None) + .expect("rename asset"); + + assert_eq!(result.previous_local_path, "assets/hero.png"); + assert_eq!(result.asset.local_path, "assets/hero-idle.png"); + assert_eq!(result.committed_project_revision, revision_before + 1); + assert_eq!( + fs::read(root.join("assets/hero-idle.png")).expect("renamed file"), + b"hero-bytes" + ); + assert!(!root.join("assets/hero.png").exists()); + + // 只改 localPath:id / kind / mediaType / source / category / tags 全部原样。 + let mut expected = before.clone(); + expected.local_path = "assets/hero-idle.png".to_string(); + assert_eq!(result.asset, expected); + assert_eq!(result.asset.id, asset_id); + assert_eq!(asset_rename_entry(&root, &asset_id), expected); + assert_eq!( + read_existing_manifest_for_project(&root) + .expect("read manifest") + .assets + .len(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + +/// 同名重命名是空操作:磁盘、manifest、revision 都不动。 +#[test] +fn rename_local_project_asset_is_a_noop_for_the_same_file_name() { + let (root, asset_id) = asset_rename_project_fixture(); + let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"); + let revision_before = asset_rename_revision(&root); + + let result = rename_local_project_asset_at(&root, &asset_id, "hero.png", None) + .expect("no-op rename"); + + assert_eq!(result.previous_local_path, "assets/hero.png"); + assert_eq!(result.asset.local_path, "assets/hero.png"); + assert_eq!(result.committed_project_revision, revision_before); + assert_eq!( + fs::read(root.join("assets/hero.png")).expect("asset file"), + b"hero-bytes" + ); + assert_eq!( + fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"), + manifest_before + ); + + fs::remove_dir_all(root).ok(); +} + +/// 空名、带路径分隔符、带 `..` 一律拒绝,且磁盘与 manifest 都不动。 +#[test] +fn rename_local_project_asset_rejects_unsafe_file_names() { + let (root, asset_id) = asset_rename_project_fixture(); + let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"); + + for name in [ + "", + " ", + "hero/idle.png", + "hero\\idle.png", + "..", + "..png", + "assets/../hero.png", + ] { + let error = rename_local_project_asset_at(&root, &asset_id, name, None) + .expect_err("unsafe file name must be rejected"); + assert!( + error.contains("不能为空") + || error.contains("路径分隔符") + || error.contains(".."), + "意外错误:{error}" + ); + } + + assert!(root.join("assets/hero.png").is_file()); + assert_eq!( + fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"), + manifest_before + ); + + fs::remove_dir_all(root).ok(); +} + +/// 跨目录改名拒绝:新名带分层目录时不可能落到资产当前目录之外,也就在原目录里找不到目标。 +#[test] +fn rename_local_project_asset_rejects_cross_directory_targets() { + let (root, asset_id) = asset_rename_project_fixture(); + fs::create_dir_all(root.join("assets/nested")).expect("nested dir"); + + let error = rename_local_project_asset_at(&root, &asset_id, "nested/hero.png", None) + .expect_err("cross directory rename must be rejected"); + assert!(error.contains("路径分隔符"), "意外错误:{error}"); + + assert!(root.join("assets/hero.png").is_file()); + assert!(!root.join("assets/nested/hero.png").exists()); + assert_eq!( + asset_rename_entry(&root, &asset_id).local_path, + "assets/hero.png" + ); + + fs::remove_dir_all(root).ok(); +} + +/// 扩展名必须与原文件一致:换扩展名、去掉扩展名、给无扩展名文件加扩展名都拒绝。 +#[test] +fn rename_local_project_asset_rejects_extension_changes() { + let (root, asset_id) = asset_rename_project_fixture(); + + for name in ["hero.jpg", "hero", "hero.png.bak"] { + let error = rename_local_project_asset_at(&root, &asset_id, name, None) + .expect_err("extension change must be rejected"); + assert!(error.contains("扩展名"), "意外错误:{error}"); + } + + write_local_project_file_at(&root, "assets/notes", "notes-bytes").expect("extensionless file"); + let extensionless = register_local_asset_at( + &root, + "assets/notes", + "asset", + "text/plain", + "canvas", + asset_rename_source(), + ) + .expect("register extensionless asset"); + let error = rename_local_project_asset_at(&root, &extensionless.id, "notes.txt", None) + .expect_err("adding an extension must be rejected"); + assert!(error.contains("扩展名"), "意外错误:{error}"); + + assert!(root.join("assets/hero.png").is_file()); + assert!(root.join("assets/notes").is_file()); + assert_eq!( + asset_rename_entry(&root, &asset_id).local_path, + "assets/hero.png" + ); + + fs::remove_dir_all(root).ok(); +} + +/// 同目录已有同名文件时拒绝,且两个文件的内容都不受影响。 +#[test] +fn rename_local_project_asset_rejects_existing_target_name() { + let (root, asset_id) = asset_rename_project_fixture(); + write_local_project_file_at(&root, "assets/other.png", "other-bytes").expect("other asset file"); + let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"); + + let error = rename_local_project_asset_at(&root, &asset_id, "other.png", None) + .expect_err("existing target must be rejected"); + assert!(error.contains("同名文件"), "意外错误:{error}"); + + assert_eq!( + fs::read(root.join("assets/hero.png")).expect("hero file"), + b"hero-bytes" + ); + assert_eq!( + fs::read(root.join("assets/other.png")).expect("other file"), + b"other-bytes" + ); + assert_eq!( + fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"), + manifest_before + ); + + fs::remove_dir_all(root).ok(); +} + +/// 目标资产必须存在:assetId 不存在、或 manifest 登记的文件在磁盘上已经没了,都拒绝。 +#[test] +fn rename_local_project_asset_requires_an_existing_asset_and_file() { + let (root, asset_id) = asset_rename_project_fixture(); + + let error = rename_local_project_asset_at(&root, "missing-asset", "hero-idle.png", None) + .expect_err("unknown asset must be rejected"); + assert!(error.contains("项目资源不存在"), "意外错误:{error}"); + + fs::remove_file(root.join("assets/hero.png")).expect("remove asset file"); + let error = rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None) + .expect_err("missing file must be rejected"); + assert!(error.contains("素材文件不存在"), "意外错误:{error}"); + assert!(!root.join("assets/hero-idle.png").exists()); + assert_eq!( + asset_rename_entry(&root, &asset_id).local_path, + "assets/hero.png" + ); + + fs::remove_dir_all(root).ok(); +} + +/// manifest 写失败必须把文件改回原名:磁盘、manifest、revision 三处都不留半成品。 +#[test] +fn rename_local_project_asset_rolls_back_the_file_when_manifest_write_fails() { + let (root, asset_id) = asset_rename_project_fixture(); + let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"); + let revision_before = asset_rename_revision(&root); + + let error = rename_local_project_asset_at( + &root, + &asset_id, + "hero-idle.png", + Some(RenameLocalProjectAssetFaultStage::ManifestWrite), + ) + .expect_err("injected manifest write failure must surface"); + assert!(error.contains("fault-injected"), "意外错误:{error}"); + + assert_eq!( + fs::read(root.join("assets/hero.png")).expect("original file restored"), + b"hero-bytes" + ); + assert!(!root.join("assets/hero-idle.png").exists()); + assert_eq!( + fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"), + manifest_before + ); + assert_eq!(asset_rename_revision(&root), revision_before); + + fs::remove_dir_all(root).ok(); +} + +/// `imageSequenceFrames` 里指向被改名文件的本地帧对齐到新路径,指向别的文件或远程地址的帧不动。 +#[test] +fn rename_local_project_asset_aligns_image_sequence_frames_of_the_renamed_file() { + let (root, asset_id) = asset_rename_project_fixture(); + mutate_manifest_at(&root, |manifest| { + let asset = manifest + .assets + .iter_mut() + .find(|asset| asset.id == asset_id) + .expect("asset entry"); + asset.image_sequence_frames = Some( + [ + ("assets/hero.png", 32u32, 32u32), + ("assets/other.png", 64, 64), + ("https://signed.invalid/frame-1.png", 16, 16), + ] + .into_iter() + .map( + |(image_src, width, height)| GameCreationAppImageSequenceFrame { + image_src: image_src.to_string(), + object_key: None, + asset_object_id: None, + width, + height, + }, + ) + .collect(), + ); + Ok(()) + }) + .expect("seed image sequence frames"); + + rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None).expect("rename asset"); + + let frames = asset_rename_entry(&root, &asset_id) + .image_sequence_frames + .expect("image sequence frames"); + assert_eq!(frames[0].image_src, "assets/hero-idle.png"); + assert_eq!(frames[0].width, 32); + assert_eq!(frames[1].image_src, "assets/other.png"); + assert_eq!(frames[2].image_src, "https://signed.invalid/frame-1.png"); + + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 1a7e0b0a3..d83966afe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -6065,6 +6065,7 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() { } mod collaboration; +mod asset_rename; mod asset_usage; mod command_runtime; pub(crate) mod configuration; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index cbd5fd45e..dcfc85406 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8224,3 +8224,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 边界:`agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,残留文件不阻塞下次启动;不要把它们当成项目写锁的同类残留处理。 - 边界:锁文件里的 PID 若超出平台进程号空间(Unix `pid_t` 是有符号 32 位、Windows 是 32 位,均恒大于 0),它不可能属于任何活进程,按“持有者不存在”直接回收,不再落回 600 秒保守分支。 - 验证:`project_lock_recovery` 11 条与 `diagnostic_log` 7 条定向测试通过,真实二进制双实例复现“第二个实例写 `startup.runner.owner-lock.failed` 并弹出可见提示”。 + +## 2026-09-10 AGC 素材重命名命令(文件改名 + localPath 更新,id 不变) + +- 语义:manifest 资产条目没有 `name` 字段,资源显示名来自 `fileName(localPath)`(`view/project-development/resourceProjectionModel.ts` 的 `label`),所以“重命名”只能是**磁盘文件改名 + manifest `localPath` 更新**,资产 `id` 不变;不新增 manifest / 契约字段。 +- 命令与实现:Tauri 命令 `rename_local_project_asset`(`commands.rs` 包装层只做 `asset.register` 权限校验),实现落在 `project/asset_rename.rs::rename_local_project_asset_at`;入参 `projectPath` / `assetId` / `newFileName`(只收新文件名,目录由 manifest 现有 `localPath` 决定,调用方无法指定目标目录)。 +- 校验判据:新名非空;不含 `/` 与 `\`(同时保证不跨目录);不含 `..`;通过 `validate_portable_project_path_component`(控制字符、Windows 保留字符、结尾点或空格、保留设备名);扩展名与原文件一致(大小写不敏感比较,无扩展名必须仍无扩展名);同目录不得已存在同名文件(先检查再 `rename`,不覆盖既有文件,只差大小写的改名按同名冲突拒绝);assetId 必须存在且登记的文件必须是磁盘上的真实普通文件。 +- 事务与回滚:持既有项目写锁(`asset.register`)后按“读 manifest → 磁盘改名 → 更新 `localPath` → 写 manifest → 推进项目 revision”顺序执行,manifest 写入仍走既有 `write_manifest` 边界(版本数组不可变校验 + 安装后回读一致性校验);写失败把文件改回原名,磁盘 / manifest / revision 三处都不留半成品,回滚也失败时两个错误都报出并标记 `reconciliation-required`。同名重命名是空操作(不动磁盘、不动 manifest、不推进 revision)。 +- 帧对齐:`imageSequenceFrames` 里**真正指向被改名文件**的本地帧对齐到新路径;判据是 `imageSrc` 为本地相对路径(非绝对路径、非带 scheme 的地址、非反斜杠路径),目录与旧文件目录一致且文件名与旧名一致(大小写不敏感)。同目录但指向别的文件的帧保持原样,避免改名连带改坏其它引用;`kind` / `mediaType` / `source` / `category` / `tags` 全部不变。 +- 已知中间状态:本切片只落 Rust 侧,前端调用方与 UI 在其他工作包;在 UI 落地前 `npm run ai-game-creator-shell:typecheck` 会因 `check-config.mjs` 要求“每个 Tauri 命令都有 App invoke 调用方”而失败,这是刻意保留的中间状态,**不要**把该命令加进 native-only 白名单来换绿。 +- 验证:`cargo test --bin genarrative-ai-game-creator-shell asset_rename` 9/9 通过(成功改名与 id 不变、同名空操作、空名 / 分隔符 / `..` 拒绝、跨目录拒绝、扩展名不一致拒绝、目标同名冲突拒绝、assetId 与文件缺失拒绝、manifest 写失败回滚、同目录帧对齐);`cargo check --all-targets` 通过;AGC 前端 69 个测试文件 / 1049 用例全绿。