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 7a3bc87d1..7baff5dbe 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_usage; mod checkpoint; mod conversation; mod export; @@ -17,6 +18,7 @@ mod resource_layout; mod verification; pub(crate) use agent_db::*; +pub(crate) use asset_usage::*; pub(crate) use checkpoint::*; pub(crate) use conversation::*; pub(crate) use export::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_usage.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_usage.rs new file mode 100644 index 000000000..73c026194 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_usage.rs @@ -0,0 +1,581 @@ +//! 运行期资源使用清单(asset usage sidecar)。 +//! +//! 游戏代码注册「运行槽位 ↔ 资源身份」的归属关系,客户端据此知道运行中的游戏在 +//! 哪用了哪个资源。清单落在 `.agent/workbench/asset-usage/usage.json`,写入遵循 +//! `resource-layouts` 同款的 flock 独占锁 + projectId fencing + revision CAS 模式。 +//! +//! ## 与 `manifest.assets` 的分工(重要) +//! +//! 本文件**不是第二份资源清单**。`manifest.assets` 是资源身份的权威真相(登记表: +//! 有哪些资源、路径、类型、哈希),本文件只记录 manifest 没有的事实: +//! +//! - 稀疏的 `slotId → resourceId` 归属(纯引用,**不复制** path / sha256 / kind / mediaType); +//! - 注册来源 `loadSite`(代码位置,随 AI 改代码而变化); +//! - 引用未登记素材时的最小例外(这类引用没有 asset ID 可引用,只能落路径); +//! - 诊断。 +//! +//! 任何需要资源元数据(路径、哈希、类型)的消费方都应回查 manifest,不要把本文件 +//! 当第二真相。 +//! +//! ## 边界 +//! +//! 资源层面的问题(引用未登记素材、引用了不存在的资源 ID、素材没有槽位归属)一律 +//! 只产出诊断,**不阻断运行**。只有构建期的脚手架校验才可能阻断。 + +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use super::filesystem::normalize_relative_path; + +const ASSET_USAGE_LOCK_PATH: &str = ".agent/workbench/asset-usage/.usage.lock"; +const ASSET_USAGE_RELATIVE_PATH: &str = ".agent/workbench/asset-usage/usage.json"; +const ASSET_USAGE_LOCK_WAIT_ATTEMPTS: usize = 100; +const ASSET_USAGE_LOCK_WAIT_MILLIS: u64 = 10; + +pub(crate) const ASSET_USAGE_SCHEMA_VERSION: &str = "agc-asset-usage.v1"; + +/// 诊断严重级别。资源问题只产生 `warning`,不阻断运行。 +pub(crate) const ASSET_USAGE_SEVERITY_WARNING: &str = "warning"; + +/// 归属来源:由游戏代码显式注册到某个运行槽位。 +pub(crate) const ASSET_USAGE_ATTRIBUTION_SLOT: &str = "slot"; +/// 归属来源:被引用但没有槽位,不可单独替换。 +pub(crate) const ASSET_USAGE_ATTRIBUTION_UNATTRIBUTED: &str = "unattributed"; +/// 归属来源:引用了未登记素材,只有路径、没有资源身份。 +pub(crate) const ASSET_USAGE_ATTRIBUTION_UNREGISTERED: &str = "unregistered"; + +/// 一条运行期资源引用。 +/// +/// `resourceId` 存在时只保存身份引用,资源元数据一律回查 manifest; +/// `resourceId` 为空表示引用的是尚未登记的素材,此时才落 `sourcePath`。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AssetUsageReference { + /// 运行槽位;没有槽位时为 `None`(不可单独替换)。 + pub slot_id: Option, + pub slot_alias: Option, + /// manifest 资产 ID;未登记素材为 `None`。 + pub resource_id: Option, + /// 仅未登记素材使用:项目相对路径,作为待登记线索。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_path: Option, + /// 注册来源,例如 `game/game.js:preload`。 + #[serde(default, skip_serializing_if = "String::is_empty")] + pub load_site: String, + pub attribution: String, +} + +/// 一条资源诊断。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AssetUsageDiagnostic { + pub code: String, + pub severity: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slot_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_path: Option, +} + +pub(crate) fn asset_usage_unregistered_asset_code() -> &'static str { + "agc.asset-usage.unregistered-asset" +} + +pub(crate) fn asset_usage_unknown_resource_id_code() -> &'static str { + "agc.asset-usage.unknown-resource-id" +} + +pub(crate) fn asset_usage_duplicate_slot_code() -> &'static str { + "agc.asset-usage.duplicate-slot" +} + +pub(crate) fn asset_usage_missing_file_code() -> &'static str { + "agc.asset-usage.missing-file" +} + +/// 落盘的清单正文。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct LocalProjectAssetUsage { + pub schema_version: String, + pub project_id: String, + pub project_revision: u64, + /// 客户端记录事实的时间,单位毫秒。纯元数据,不参与业务判定。 + pub updated_at_ms: u64, + /// 运行期资源引用;`slotId` 升序、`resourceId` 次序稳定。 + pub references: Vec, + pub diagnostics: Vec, +} + +impl LocalProjectAssetUsage { + /// 运行槽位 → 资源身份的替换绑定。无槽位与未登记引用不产生绑定。 + pub(crate) fn slot_bindings(&self) -> Vec<(String, String)> { + let mut bindings = Vec::new(); + for reference in &self.references { + let (Some(slot_id), Some(resource_id)) = + (reference.slot_id.as_ref(), reference.resource_id.as_ref()) + else { + continue; + }; + if reference.attribution != ASSET_USAGE_ATTRIBUTION_SLOT { + continue; + } + bindings.push((slot_id.clone(), resource_id.clone())); + } + bindings + } + + /// 按资源身份统计被哪些槽位使用。 + pub(crate) fn slots_by_resource_id(&self) -> Vec<(String, Vec)> { + let mut grouped: Vec<(String, Vec)> = Vec::new(); + for reference in &self.references { + let (Some(resource_id), Some(slot_id)) = + (reference.resource_id.as_ref(), reference.slot_id.as_ref()) + else { + continue; + }; + match grouped.iter_mut().find(|(id, _)| id == resource_id) { + Some((_, slots)) => { + if !slots.iter().any(|slot| slot == slot_id) { + slots.push(slot_id.clone()); + } + } + None => grouped.push((resource_id.clone(), vec![slot_id.clone()])), + } + } + grouped.sort_by(|left, right| left.0.cmp(&right.0)); + grouped + } +} + +/// 游戏代码提交的一次资源使用状态。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct SubmitLocalProjectAssetUsageInput { + /// 当前项目 revision;用于 CAS,不允许回退。 + pub project_revision: u64, + #[serde(default)] + pub references: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct SubmitLocalProjectAssetUsageReference { + #[serde(default)] + pub slot_id: Option, + #[serde(default)] + pub slot_alias: Option, + /// 已登记资源给 ID;未登记素材留空并给 `sourcePath`。 + #[serde(default)] + pub resource_id: Option, + #[serde(default)] + pub source_path: Option, + #[serde(default)] + pub load_site: Option, +} + +/// 登记/读取后的清单快照。 +pub(crate) type SubmitLocalProjectAssetUsageResult = LocalProjectAssetUsage; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ReadLocalProjectAssetUsageInput { + #[serde(default)] + pub project_id: Option, +} + +/// 项目内的资源事实(来自 manifest,本模块只读不写)。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct LocalProjectAssetFacts { + pub asset_id: String, + pub local_path: String, +} + +struct AssetUsageWriteLock { + _file: File, +} + +static ASSET_USAGE_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); + +pub(crate) fn asset_usage_relative_path() -> &'static str { + ASSET_USAGE_RELATIVE_PATH +} + +fn asset_usage_path(root: &Path) -> Result { + Ok(root.join(normalize_relative_path(ASSET_USAGE_RELATIVE_PATH)?)) +} + +#[cfg(unix)] +fn try_open_asset_usage_write_lock_file(root: &Path) -> Result, String> { + use std::os::unix::io::AsRawFd; + + let _open_guard = ASSET_USAGE_LOCK_OPEN_GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "资源使用清单锁打开状态已损坏".to_string())?; + + let relative_path = normalize_relative_path(ASSET_USAGE_LOCK_PATH)?; + let path = root.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建资源使用清单锁目录 {} 失败:{error}", parent.display()))?; + } + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .map_err(|error| format!("打开资源使用清单锁 {} 失败:{error}", path.display()))?; + + // SAFETY: flock observes only the live fd owned by `file`; dropping it releases the lock. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return Ok(Some(file)); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::WouldBlock { + return Ok(None); + } + Err(format!("获取资源使用清单锁 {} 失败:{error}", path.display())) +} + +#[cfg(windows)] +fn try_open_asset_usage_write_lock_file(root: &Path) -> Result, String> { + use std::os::windows::fs::OpenOptionsExt; + + const ERROR_SHARING_VIOLATION: i32 = 32; + const ERROR_LOCK_VIOLATION: i32 = 33; + + let _open_guard = ASSET_USAGE_LOCK_OPEN_GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "资源使用清单锁打开状态已损坏".to_string())?; + + let relative_path = normalize_relative_path(ASSET_USAGE_LOCK_PATH)?; + let path = root.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建资源使用清单锁目录 {} 失败:{error}", parent.display()))?; + } + match OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .share_mode(0) + .open(&path) + { + Ok(file) => Ok(Some(file)), + Err(error) + if matches!( + error.raw_os_error(), + Some(ERROR_SHARING_VIOLATION) | Some(ERROR_LOCK_VIOLATION) + ) => + { + Ok(None) + } + Err(error) => Err(format!("获取资源使用清单锁 {} 失败:{error}", path.display())), + } +} + +#[cfg(not(any(unix, windows)))] +fn try_open_asset_usage_write_lock_file(root: &Path) -> Result, String> { + let _ = root; + Err("当前平台不支持资源使用清单文件锁".to_string()) +} + +fn acquire_asset_usage_write_lock(root: &Path) -> Result { + for attempt in 0..ASSET_USAGE_LOCK_WAIT_ATTEMPTS { + if let Some(file) = try_open_asset_usage_write_lock_file(root)? { + return Ok(AssetUsageWriteLock { _file: file }); + } + if attempt + 1 < ASSET_USAGE_LOCK_WAIT_ATTEMPTS { + std::thread::sleep(Duration::from_millis(ASSET_USAGE_LOCK_WAIT_MILLIS)); + } + } + Err("资源使用清单正在被另一个写入方占用,请稍后重试".to_string()) +} + +/// 读取清单。文件缺失、schema 不符或 projectId 不匹配时返回 `None`(等价于"尚未登记")。 +pub(crate) fn read_local_project_asset_usage( + root: &Path, + expected_project_id: Option<&str>, +) -> Result, String> { + let path = asset_usage_path(root)?; + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!("读取资源使用清单 {} 失败:{error}", path.display())); + } + }; + let usage: LocalProjectAssetUsage = match serde_json::from_slice(&bytes) { + Ok(usage) => usage, + Err(_) => return Ok(None), + }; + if usage.schema_version != ASSET_USAGE_SCHEMA_VERSION { + return Ok(None); + } + if let Some(expected) = expected_project_id { + if usage.project_id != expected { + return Ok(None); + } + } + Ok(Some(usage)) +} + +fn write_local_project_asset_usage( + root: &Path, + usage: &LocalProjectAssetUsage, +) -> Result<(), String> { + let path = asset_usage_path(root)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建资源使用清单目录 {} 失败:{error}", parent.display()))?; + } + let payload = serde_json::to_vec_pretty(usage) + .map_err(|error| format!("序列化资源使用清单失败:{error}"))?; + let temporary = path.with_extension("json.tmp"); + { + let mut file = File::create(&temporary) + .map_err(|error| format!("写入资源使用清单临时文件失败:{error}"))?; + file.write_all(&payload) + .map_err(|error| format!("写入资源使用清单临时文件失败:{error}"))?; + file.sync_all() + .map_err(|error| format!("刷新资源使用清单临时文件失败:{error}"))?; + } + fs::rename(&temporary, &path) + .map_err(|error| format!("安装资源使用清单 {} 失败:{error}", path.display()))?; + Ok(()) +} + +/// 项目相对路径对应文件是否存在且可读。用于"引用了不存在的素材"诊断。 +fn project_relative_file_exists(root: &Path, relative_path: &str) -> bool { + match normalize_relative_path(relative_path) { + Ok(normalized) => root.join(normalized).is_file(), + Err(_) => false, + } +} + +fn trimmed(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|item| !item.is_empty()) +} + +/// 登记一次游戏代码提交的资源使用状态。 +/// +/// 语义: +/// - `projectRevision` 只允许前进;回退时拒绝写入;同 revision 重放是幂等的(整体替换)。 +/// - 同一槽位注册多次 → 保留首条并产出 `duplicate-slot` 诊断。 +/// - 给了 `resourceId` 但 manifest 里不存在 → 产出 `unknown-resource-id` 诊断, +/// 并降级为未登记引用(保留身份字符串,便于 AI 对照修复)。 +/// - 没给 `resourceId` 但有 `sourcePath` → `unregistered` 引用 + `unregistered-asset` 诊断。 +/// - 槽位与资源都没有 → `unattributed` 引用(不可单独替换)。 +/// - 登记过程中引用的文件在磁盘上找不到 → `missing-file` 诊断。 +pub(crate) fn submit_local_project_asset_usage_at( + root: &Path, + project_id: &str, + assets: &[LocalProjectAssetFacts], + input: &SubmitLocalProjectAssetUsageInput, +) -> Result { + if project_id.trim().is_empty() { + return Err("资源使用登记缺少项目身份".to_string()); + } + let _lock = acquire_asset_usage_write_lock(root)?; + + if let Some(existing) = read_local_project_asset_usage(root, Some(project_id))? { + if input.project_revision < existing.project_revision { + return Err(format!( + "资源使用登记的 revision {} 小于已登记 revision {}", + input.project_revision, existing.project_revision + )); + } + } + + let mut references: Vec = Vec::new(); + let mut diagnostics: Vec = Vec::new(); + let mut seen_slots: Vec = Vec::new(); + + for submitted in &input.references { + let slot_id = trimmed(submitted.slot_id.as_deref()); + let resource_id = trimmed(submitted.resource_id.as_deref()); + let source_path = trimmed(submitted.source_path.as_deref()); + let slot_alias = trimmed(submitted.slot_alias.as_deref()).map(str::to_string); + let load_site = trimmed(submitted.load_site.as_deref()).unwrap_or_default(); + + if let Some(slot_id) = slot_id { + if seen_slots.iter().any(|value| value == slot_id) { + diagnostics.push(AssetUsageDiagnostic { + code: asset_usage_duplicate_slot_code().to_string(), + severity: ASSET_USAGE_SEVERITY_WARNING.to_string(), + message: format!("运行槽位 {slot_id} 在一个项目里注册了多次,只保留首次注册"), + slot_id: Some(slot_id.to_string()), + resource_id: resource_id.map(str::to_string), + source_path: source_path.map(str::to_string), + }); + continue; + } + seen_slots.push(slot_id.to_string()); + } + + let manifest_path = resource_id.and_then(|id| { + assets + .iter() + .find(|asset| asset.asset_id == id) + .map(|asset| asset.local_path.as_str()) + }); + + let (attribution, resolved_resource_id, resolved_source_path) = + match (resource_id, manifest_path) { + (Some(id), Some(path)) => { + // 已登记:只保留身份引用,路径与哈希回查 manifest。 + if !project_relative_file_exists(root, path) { + diagnostics.push(AssetUsageDiagnostic { + code: asset_usage_missing_file_code().to_string(), + severity: ASSET_USAGE_SEVERITY_WARNING.to_string(), + message: format!("已登记资源 {id} 的素材文件不存在:{path}"), + slot_id: slot_id.map(str::to_string), + resource_id: Some(id.to_string()), + source_path: Some(path.to_string()), + }); + } + ( + if slot_id.is_some() { + ASSET_USAGE_ATTRIBUTION_SLOT + } else { + ASSET_USAGE_ATTRIBUTION_UNATTRIBUTED + } + .to_string(), + Some(id.to_string()), + None, + ) + } + (Some(id), None) => { + diagnostics.push(AssetUsageDiagnostic { + code: asset_usage_unknown_resource_id_code().to_string(), + severity: ASSET_USAGE_SEVERITY_WARNING.to_string(), + message: format!("引用了 manifest 中不存在的资源 ID:{id}"), + slot_id: slot_id.map(str::to_string), + resource_id: Some(id.to_string()), + source_path: source_path.map(str::to_string), + }); + check_source_path_file(root, source_path, &mut diagnostics, slot_id, Some(id)); + ( + ASSET_USAGE_ATTRIBUTION_UNREGISTERED.to_string(), + Some(id.to_string()), + source_path.map(str::to_string), + ) + } + (None, _) => { + if let Some(path) = source_path { + diagnostics.push(AssetUsageDiagnostic { + code: asset_usage_unregistered_asset_code().to_string(), + severity: ASSET_USAGE_SEVERITY_WARNING.to_string(), + message: format!("引用了未登记素材:{path}"), + slot_id: slot_id.map(str::to_string), + resource_id: None, + source_path: Some(path.to_string()), + }); + } + check_source_path_file(root, source_path, &mut diagnostics, slot_id, None); + ( + if slot_id.is_some() { + ASSET_USAGE_ATTRIBUTION_UNREGISTERED + } else { + ASSET_USAGE_ATTRIBUTION_UNATTRIBUTED + } + .to_string(), + None, + source_path.map(str::to_string), + ) + } + }; + + // 无槽位、无资源、无路径的引用没有任何信息量,跳过以免污染清单。 + if slot_id.is_none() && resolved_resource_id.is_none() && resolved_source_path.is_none() { + continue; + } + + references.push(AssetUsageReference { + slot_id: slot_id.map(str::to_string), + slot_alias, + resource_id: resolved_resource_id, + source_path: resolved_source_path, + load_site: load_site.to_string(), + attribution, + }); + } + + references.sort_by(|left, right| { + left.slot_id + .as_deref() + .unwrap_or("") + .cmp(right.slot_id.as_deref().unwrap_or("")) + .then_with(|| { + left.resource_id + .as_deref() + .unwrap_or("") + .cmp(right.resource_id.as_deref().unwrap_or("")) + }) + .then_with(|| { + left.source_path + .as_deref() + .unwrap_or("") + .cmp(right.source_path.as_deref().unwrap_or("")) + }) + }); + diagnostics.sort_by(|left, right| { + left.code + .cmp(&right.code) + .then_with(|| left.slot_id.cmp(&right.slot_id)) + .then_with(|| left.source_path.cmp(&right.source_path)) + }); + + let usage = LocalProjectAssetUsage { + schema_version: ASSET_USAGE_SCHEMA_VERSION.to_string(), + project_id: project_id.to_string(), + project_revision: input.project_revision, + updated_at_ms: unix_timestamp_millis(), + references, + diagnostics, + }; + write_local_project_asset_usage(root, &usage)?; + Ok(usage) +} + +fn check_source_path_file( + root: &Path, + source_path: Option<&str>, + diagnostics: &mut Vec, + slot_id: Option<&str>, + resource_id: Option<&str>, +) { + let Some(path) = source_path else { + return; + }; + if project_relative_file_exists(root, path) { + return; + } + diagnostics.push(AssetUsageDiagnostic { + code: asset_usage_missing_file_code().to_string(), + severity: ASSET_USAGE_SEVERITY_WARNING.to_string(), + message: format!("引用的素材文件不存在:{path}"), + slot_id: slot_id.map(str::to_string), + resource_id: resource_id.map(str::to_string), + source_path: Some(path.to_string()), + }); +} + +fn unix_timestamp_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default() +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_usage.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_usage.rs new file mode 100644 index 000000000..dbd3c3885 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_usage.rs @@ -0,0 +1,400 @@ +use super::*; + +/// 建一个最小项目目录,并在 `assets/` 下写入一个真实素材文件。 +fn asset_usage_project_fixture() -> PathBuf { + let root = unique_project_path(); + fs::create_dir_all(root.join("assets")).expect("create asset usage fixture assets dir"); + root +} + +fn write_asset_usage_asset(root: &Path, relative_path: &str, contents: &[u8]) { + let path = root.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create asset usage fixture asset parent"); + } + fs::write(&path, contents).expect("write asset usage fixture asset"); +} + +fn asset_usage_reference( + slot_id: Option<&str>, + resource_id: Option<&str>, + source_path: Option<&str>, +) -> SubmitLocalProjectAssetUsageReference { + SubmitLocalProjectAssetUsageReference { + slot_id: slot_id.map(str::to_string), + slot_alias: None, + resource_id: resource_id.map(str::to_string), + source_path: source_path.map(str::to_string), + load_site: None, + } +} + +fn asset_usage_submit( + project_revision: u64, + references: Vec, +) -> SubmitLocalProjectAssetUsageInput { + SubmitLocalProjectAssetUsageInput { + project_revision, + references, + } +} + +fn asset_usage_diagnostic_codes(usage: &LocalProjectAssetUsage) -> Vec<&str> { + usage + .diagnostics + .iter() + .map(|diagnostic| diagnostic.code.as_str()) + .collect() +} + +/// 已登记资源:只保留身份引用,不复制路径,且能给出槽位绑定。 +#[test] +fn asset_usage_registers_registered_slot_as_sparse_reference() { + let root = asset_usage_project_fixture(); + write_asset_usage_asset(&root, "assets/hero.png", b"hero-bytes"); + let assets = vec![LocalProjectAssetFacts { + asset_id: "hero-image".to_string(), + local_path: "assets/hero.png".to_string(), + }]; + + let mut reference = asset_usage_reference(Some("hero"), Some("hero-image"), None); + reference.load_site = Some("game/game.js:preload".to_string()); + let usage = submit_local_project_asset_usage_at( + &root, + "project-1", + &assets, + &asset_usage_submit(7, vec![reference]), + ) + .expect("register asset usage"); + + assert_eq!(usage.schema_version, ASSET_USAGE_SCHEMA_VERSION); + assert_eq!(usage.project_id, "project-1"); + assert_eq!(usage.project_revision, 7); + assert_eq!(usage.references.len(), 1); + let entry = &usage.references[0]; + assert_eq!(entry.slot_id.as_deref(), Some("hero")); + assert_eq!(entry.resource_id.as_deref(), Some("hero-image")); + assert_eq!(entry.attribution, ASSET_USAGE_ATTRIBUTION_SLOT); + assert_eq!(entry.load_site, "game/game.js:preload"); + // 关键:已登记资源不复制 manifest 已有的路径/哈希,避免第二份资源清单。 + assert_eq!(entry.source_path, None); + assert!(usage.diagnostics.is_empty()); + assert_eq!( + usage.slot_bindings(), + vec![("hero".to_string(), "hero-image".to_string())] + ); + assert_eq!( + usage.slots_by_resource_id(), + vec![( + "hero-image".to_string(), + vec!["hero".to_string()] + )] + ); + + fs::remove_dir_all(root).ok(); +} + +/// 引用了 manifest 里不存在的资源 ID:产出诊断、保留身份、降级为非槽位绑定。 +#[test] +fn asset_usage_reports_unknown_resource_id_without_blocking() { + let root = asset_usage_project_fixture(); + let usage = submit_local_project_asset_usage_at( + &root, + "project-1", + &[], + &asset_usage_submit( + 1, + vec![asset_usage_reference(Some("hero"), Some("ghost"), None)], + ), + ) + .expect("register unknown resource reference"); + + assert_eq!( + asset_usage_diagnostic_codes(&usage), + vec![asset_usage_unknown_resource_id_code()] + ); + assert_eq!( + usage.references[0].attribution, + ASSET_USAGE_ATTRIBUTION_UNREGISTERED + ); + assert_eq!(usage.references[0].resource_id.as_deref(), Some("ghost")); + // 未登记引用不产生替换绑定。 + assert!(usage.slot_bindings().is_empty()); + + fs::remove_dir_all(root).ok(); +} + +/// 未登记素材:带路径、产出诊断、路径落进 sourcePath 作为待登记线索。 +#[test] +fn asset_usage_reports_unregistered_asset_with_source_path() { + let root = asset_usage_project_fixture(); + write_asset_usage_asset(&root, "assets/unregistered.png", b"raw-bytes"); + let usage = submit_local_project_asset_usage_at( + &root, + "project-1", + &[], + &asset_usage_submit( + 2, + vec![asset_usage_reference( + Some("banner"), + None, + Some("assets/unregistered.png"), + )], + ), + ) + .expect("register unregistered reference"); + + assert_eq!( + asset_usage_diagnostic_codes(&usage), + vec![asset_usage_unregistered_asset_code()] + ); + let entry = &usage.references[0]; + assert_eq!(entry.resource_id, None); + assert_eq!(entry.source_path.as_deref(), Some("assets/unregistered.png")); + assert_eq!(entry.attribution, ASSET_USAGE_ATTRIBUTION_UNREGISTERED); + + fs::remove_dir_all(root).ok(); +} + +/// 同一个槽位注册多次:保留首次,产出 duplicate-slot 诊断。 +#[test] +fn asset_usage_keeps_first_registration_for_duplicate_slot() { + let root = asset_usage_project_fixture(); + let assets = vec![ + LocalProjectAssetFacts { + asset_id: "first".to_string(), + local_path: "assets/first.png".to_string(), + }, + LocalProjectAssetFacts { + asset_id: "second".to_string(), + local_path: "assets/second.png".to_string(), + }, + ]; + write_asset_usage_asset(&root, "assets/first.png", b"first"); + write_asset_usage_asset(&root, "assets/second.png", b"second"); + + let usage = submit_local_project_asset_usage_at( + &root, + "project-1", + &assets, + &asset_usage_submit( + 1, + vec![ + asset_usage_reference(Some("hero"), Some("first"), None), + asset_usage_reference(Some("hero"), Some("second"), None), + ], + ), + ) + .expect("register duplicate slot"); + + assert_eq!(usage.references.len(), 1); + assert_eq!(usage.references[0].resource_id.as_deref(), Some("first")); + assert_eq!( + asset_usage_diagnostic_codes(&usage), + vec![asset_usage_duplicate_slot_code()] + ); + + fs::remove_dir_all(root).ok(); +} + +/// 引用的文件不存在:产出 missing-file 诊断,但仍然登记,不阻断。 +#[test] +fn asset_usage_reports_missing_file_reference() { + let root = asset_usage_project_fixture(); + let usage = submit_local_project_asset_usage_at( + &root, + "project-1", + &[], + &asset_usage_submit( + 3, + vec![asset_usage_reference( + Some("ghost-slot"), + None, + Some("assets/missing.png"), + )], + ), + ) + .expect("register missing file reference"); + + // 未登记素材 + 文件不存在是两条互补诊断,按 code 排序输出。 + assert_eq!( + asset_usage_diagnostic_codes(&usage), + vec![ + asset_usage_missing_file_code(), + asset_usage_unregistered_asset_code() + ] + ); + assert_eq!(usage.references.len(), 1); + + fs::remove_dir_all(root).ok(); +} + +/// 没有槽位的引用:记 unattributed,不产生替换绑定,不产出诊断。 +#[test] +fn asset_usage_records_unattributed_reference_without_diagnostic() { + let root = asset_usage_project_fixture(); + let assets = vec![LocalProjectAssetFacts { + asset_id: "style-doc".to_string(), + local_path: "memory/style.md".to_string(), + }]; + write_asset_usage_asset(&root, "memory/style.md", b"# style"); + + let usage = submit_local_project_asset_usage_at( + &root, + "project-1", + &assets, + &asset_usage_submit( + 4, + vec![asset_usage_reference(None, Some("style-doc"), None)], + ), + ) + .expect("register unattributed reference"); + + assert!(usage.diagnostics.is_empty()); + assert_eq!( + usage.references[0].attribution, + ASSET_USAGE_ATTRIBUTION_UNATTRIBUTED + ); + assert!(usage.slot_bindings().is_empty()); + + fs::remove_dir_all(root).ok(); +} + +/// 幂等重放:同一 revision 重复登记结果一致。 +#[test] +fn asset_usage_replay_at_same_revision_is_idempotent() { + let root = asset_usage_project_fixture(); + let assets = vec![LocalProjectAssetFacts { + asset_id: "hero-image".to_string(), + local_path: "assets/hero.png".to_string(), + }]; + write_asset_usage_asset(&root, "assets/hero.png", b"hero"); + + let input = asset_usage_submit( + 5, + vec![asset_usage_reference(Some("hero"), Some("hero-image"), None)], + ); + let first = submit_local_project_asset_usage_at(&root, "project-1", &assets, &input) + .expect("first asset usage submission"); + let second = submit_local_project_asset_usage_at(&root, "project-1", &assets, &input) + .expect("replayed asset usage submission"); + + assert_eq!(first.references, second.references); + assert_eq!(first.diagnostics, second.diagnostics); + assert_eq!(first.project_revision, second.project_revision); + + fs::remove_dir_all(root).ok(); +} + +/// revision 回退被拒绝:清单只允许随项目 revision 前进。 +#[test] +fn asset_usage_rejects_revision_regression() { + let root = asset_usage_project_fixture(); + submit_local_project_asset_usage_at( + &root, + "project-1", + &[], + &asset_usage_submit( + 9, + vec![asset_usage_reference( + Some("hero"), + None, + Some("assets/missing.png"), + )], + ), + ) + .expect("register forward revision"); + + let error = submit_local_project_asset_usage_at( + &root, + "project-1", + &[], + &asset_usage_submit(8, vec![]), + ) + .expect_err("regressed revision must be rejected"); + assert!(error.contains("小于已登记 revision"), "unexpected: {error}"); + + fs::remove_dir_all(root).ok(); +} + +/// projectId 不匹配的清单按"尚未登记"处理,不串项目。 +#[test] +fn asset_usage_read_ignores_other_project_identity() { + let root = asset_usage_project_fixture(); + submit_local_project_asset_usage_at( + &root, + "project-1", + &[], + &asset_usage_submit( + 1, + vec![asset_usage_reference( + Some("hero"), + None, + Some("assets/missing.png"), + )], + ), + ) + .expect("register project scoped usage"); + + assert!( + read_local_project_asset_usage(&root, Some("project-2")) + .expect("read other project usage") + .is_none() + ); + assert!( + read_local_project_asset_usage(&root, Some("project-1")) + .expect("read own project usage") + .is_some() + ); + + fs::remove_dir_all(root).ok(); +} + +/// 清单缺失时读取返回 None,而不是报错。 +#[test] +fn asset_usage_read_returns_none_when_absent() { + let root = asset_usage_project_fixture(); + assert!( + read_local_project_asset_usage(&root, Some("project-1")) + .expect("read absent asset usage") + .is_none() + ); + fs::remove_dir_all(root).ok(); +} + +/// 落盘正文只包含引用与诊断,不含 manifest 已有的资源元数据。 +#[test] +fn asset_usage_persisted_payload_omits_manifest_owned_metadata() { + let root = asset_usage_project_fixture(); + let assets = vec![LocalProjectAssetFacts { + asset_id: "hero-image".to_string(), + local_path: "assets/hero.png".to_string(), + }]; + write_asset_usage_asset(&root, "assets/hero.png", b"hero"); + let mut reference = asset_usage_reference(Some("hero"), Some("hero-image"), None); + reference.load_site = Some("game/game.js:preload".to_string()); + submit_local_project_asset_usage_at( + &root, + "project-1", + &assets, + &asset_usage_submit(1, vec![reference]), + ) + .expect("register usage for payload inspection"); + + let payload = fs::read_to_string(root.join(asset_usage_relative_path())) + .expect("read persisted asset usage payload"); + for forbidden in ["sha256", "mediaType", "localPath", "kind"] { + assert!( + !payload.contains(forbidden), + "asset usage payload must not duplicate manifest field {forbidden}: {payload}" + ); + } + for required in ["slotId", "resourceId", "loadSite", "attribution"] { + assert!( + payload.contains(required), + "asset usage payload missing {required}: {payload}" + ); + } + + 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 e2c15a956..1a7e0b0a3 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_usage; mod command_runtime; pub(crate) mod configuration; mod goal;