收敛 AGC 资源口径:撤掉运行期使用侧车并改为确认式删除
- 整体删除运行期资源使用侧车模块 project/asset_usage.rs 与 tests/asset_usage.rs(含 16 条测试),并清理 project.rs、tests/mod.rs 里的 mod / use
- game_iteration_resource_bindings 收敛为纯恒等映射 slotId=asset:{assetId}、resourceId=assetId,不再读取侧车、不再做槽位优先
- assets.rs 删除 GAME_ITERATION_IDENTITY_BINDING_PREFIX、asset_is_bound_to_runtime_slot 与 remove_asset_usage_references,删除不再判定运行槽位、不再清理侧车引用
- manifest 写入层把“版本数组只允许追加”校验改为显式接收放行删除的版本 ID:新增 mutate_manifest_at_allowing_version_removals,其余写入路径继续传空放行集合
- delete_manifest_asset_at 改为三分支语义:无任何版本引用直接删登记、被引用且未确认时只删登记并原样保留悬空绑定、被引用且确认时素材与相关版本在同一次 manifest 写入里一起删除
- DeleteLocalProjectAssetInput 增加 deleteReferencedVersions 入参,commands.rs 与 main.rs 同步
- 新增只读命令 read_local_project_asset_references,列出引用了该素材的版本标识、revision 与创建时间
- 新增 tests/asset_delete.rs,覆盖恒等绑定、无引用删除、未确认保留悬空绑定、确认连带删除、引用投影、只允许追加校验仍拒绝其它路径与 CAS
This commit is contained in:
@@ -1795,34 +1795,71 @@ pub(crate) fn register_local_asset_entry(
|
||||
})
|
||||
}
|
||||
|
||||
/// 恒等绑定槽位前缀:`asset:{assetId}`。
|
||||
///
|
||||
/// 版本创建时会给当时 manifest 里的每个资源补一条恒等绑定,作为"未被运行槽位覆盖"的
|
||||
/// 兼容兜底(历史版本与既有校验语义依赖它)。因此恒等绑定**只表达登记关系**,不表达
|
||||
/// "这个素材真的被游戏用到了"。
|
||||
pub(crate) const GAME_ITERATION_IDENTITY_BINDING_PREFIX: &str = "asset:";
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct ReadLocalProjectAssetReferencesInput {
|
||||
pub project_path: String,
|
||||
pub asset_id: String,
|
||||
}
|
||||
|
||||
/// 一个素材是否被任意版本的**真实运行槽位**绑定过。
|
||||
/// 引用了某个素材的项目版本摘要。删除弹窗据此列出「被哪些版本使用」。
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct LocalProjectAssetReferenceVersion {
|
||||
pub version_id: String,
|
||||
pub project_revision: u64,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ReadLocalProjectAssetReferencesResult {
|
||||
pub asset_id: String,
|
||||
pub versions: Vec<LocalProjectAssetReferenceVersion>,
|
||||
}
|
||||
|
||||
/// 版本数组里 `resourceBindings` 指向该素材的版本,顺序与版本数组一致。
|
||||
///
|
||||
/// C5 删除规则读作"从未被任何 slot 绑定过的素材才可删"。这个判断只看真实槽位绑定:
|
||||
/// 恒等绑定是版本创建时的兼容兜底,若把它也算作"被引用",那么任何在某个版本创建之前
|
||||
/// 登记过的素材都永远删不掉,删除规则会完全失效。历史版本本身仍然不可变、不影响回放。
|
||||
fn asset_is_bound_to_runtime_slot(
|
||||
versions: &[GameIterationVersion],
|
||||
/// 版本绑定即资源记录:`resourceId` 指向 manifest 资产 ID,命中即表示该版本用了这个素材。
|
||||
fn asset_referencing_versions(
|
||||
manifest: &GameCreationAppManifest,
|
||||
asset_id: &str,
|
||||
) -> Option<(String, String)> {
|
||||
for version in versions {
|
||||
for binding in &version.resource_bindings {
|
||||
if binding.resource_id != asset_id {
|
||||
continue;
|
||||
}
|
||||
if binding.slot_id.starts_with(GAME_ITERATION_IDENTITY_BINDING_PREFIX) {
|
||||
continue;
|
||||
}
|
||||
return Some((version.version_id.clone(), binding.slot_id.clone()));
|
||||
}
|
||||
) -> Vec<LocalProjectAssetReferenceVersion> {
|
||||
manifest
|
||||
.versions
|
||||
.iter()
|
||||
.filter(|version| {
|
||||
version
|
||||
.resource_bindings
|
||||
.iter()
|
||||
.any(|binding| binding.resource_id == asset_id)
|
||||
})
|
||||
.map(|version| LocalProjectAssetReferenceVersion {
|
||||
version_id: version.version_id.clone(),
|
||||
project_revision: version.project_revision,
|
||||
created_at: version.created_at,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalized_asset_id(asset_id: &str) -> Result<&str, String> {
|
||||
let asset_id = asset_id.trim();
|
||||
if asset_id.is_empty() {
|
||||
return Err("素材 assetId 不能为空".to_string());
|
||||
}
|
||||
None
|
||||
Ok(asset_id)
|
||||
}
|
||||
|
||||
pub(crate) fn read_manifest_asset_references_at(
|
||||
root: &Path,
|
||||
asset_id: &str,
|
||||
) -> Result<ReadLocalProjectAssetReferencesResult, String> {
|
||||
let asset_id = normalized_asset_id(asset_id)?;
|
||||
let manifest = read_existing_manifest_for_project(root)?;
|
||||
Ok(ReadLocalProjectAssetReferencesResult {
|
||||
asset_id: asset_id.to_string(),
|
||||
versions: asset_referencing_versions(&manifest, asset_id),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -1832,6 +1869,8 @@ pub(crate) struct DeleteLocalProjectAssetInput {
|
||||
pub expected_project_id: String,
|
||||
pub expected_project_revision: u64,
|
||||
pub asset_id: String,
|
||||
/// 用户是否勾选「把相关游戏版本一并删除」;默认不勾,只删素材登记。
|
||||
pub delete_referenced_versions: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
@@ -1844,13 +1883,14 @@ pub(crate) struct DeleteLocalProjectAssetResult {
|
||||
pub file_retained: bool,
|
||||
}
|
||||
|
||||
/// 删除一个从未被真实运行槽位绑定过的素材登记。
|
||||
/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。
|
||||
///
|
||||
/// 语义:
|
||||
/// - 未被任何版本引用 → 只摘掉登记;
|
||||
/// - 被引用且 `delete_referenced_versions` 为 `false` → 只摘掉登记,引用它的版本原样保留为
|
||||
/// 悬空绑定,不做二次拦截;界面不为这些版本合成资源卡;
|
||||
/// - 被引用且为 `true` → 素材与所有引用它的版本在**同一次 manifest 写入**里一起删除;
|
||||
/// - 素材不可变,**不删除磁盘文件**,只摘掉 manifest 登记;
|
||||
/// - 被任意版本的真实运行槽位绑定过 → 拒绝,并在错误里给出版本与槽位;
|
||||
/// - 删除同时清理运行期资源使用清单里指向它的引用,避免留下悬空归属;
|
||||
/// - 已完成的版本记录本身不改动,历史回放不受影响;
|
||||
/// - 与资源分类更新同口径:持项目写锁后按 `expectedProjectId` / 当前 revision 做 CAS,
|
||||
/// 失败时 manifest 与 revision 都不变。
|
||||
pub(crate) fn delete_manifest_asset_at(
|
||||
@@ -1858,6 +1898,7 @@ pub(crate) fn delete_manifest_asset_at(
|
||||
expected_project_id: &str,
|
||||
expected_project_revision: u64,
|
||||
asset_id: &str,
|
||||
delete_referenced_versions: bool,
|
||||
) -> Result<DeleteLocalProjectAssetResult, String> {
|
||||
if expected_project_revision
|
||||
> shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
|
||||
@@ -1868,10 +1909,7 @@ pub(crate) fn delete_manifest_asset_at(
|
||||
if expected_project_id.is_empty() {
|
||||
return Err("素材删除 expectedProjectId 不能为空".to_string());
|
||||
}
|
||||
let asset_id = asset_id.trim();
|
||||
if asset_id.is_empty() {
|
||||
return Err("素材删除 assetId 不能为空".to_string());
|
||||
}
|
||||
let asset_id = normalized_asset_id(asset_id)?;
|
||||
|
||||
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
|
||||
return Err("project-identity-conflict".to_string());
|
||||
@@ -1886,28 +1924,40 @@ pub(crate) fn delete_manifest_asset_at(
|
||||
}
|
||||
|
||||
let mut local_path = String::new();
|
||||
mutate_manifest_at(root, |manifest| {
|
||||
let Some(index) = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.position(|asset| asset.id == asset_id)
|
||||
else {
|
||||
return Err(format!("项目资源不存在:{asset_id}"));
|
||||
};
|
||||
if let Some((version_id, slot_id)) =
|
||||
asset_is_bound_to_runtime_slot(&manifest.versions, asset_id)
|
||||
{
|
||||
return Err(format!(
|
||||
"素材 {asset_id} 已被版本 {version_id} 的运行槽位 {slot_id} 绑定,不能删除"
|
||||
));
|
||||
}
|
||||
local_path = manifest.assets[index].local_path.clone();
|
||||
manifest.assets.remove(index);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
// 清理使用清单里指向该素材的引用;清单缺失或读取失败不阻塞删除结果。
|
||||
let _ = remove_asset_usage_references(root, asset_id);
|
||||
mutate_manifest_at_allowing_version_removals(
|
||||
root,
|
||||
&|manifest| {
|
||||
if !delete_referenced_versions {
|
||||
return Vec::new();
|
||||
}
|
||||
asset_referencing_versions(manifest, asset_id)
|
||||
.into_iter()
|
||||
.map(|version| version.version_id)
|
||||
.collect()
|
||||
},
|
||||
|manifest| {
|
||||
let Some(index) = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.position(|asset| asset.id == asset_id)
|
||||
else {
|
||||
return Err(format!("项目资源不存在:{asset_id}"));
|
||||
};
|
||||
local_path = manifest.assets[index].local_path.clone();
|
||||
manifest.assets.remove(index);
|
||||
if delete_referenced_versions {
|
||||
let referenced_version_ids: Vec<String> =
|
||||
asset_referencing_versions(manifest, asset_id)
|
||||
.into_iter()
|
||||
.map(|version| version.version_id)
|
||||
.collect();
|
||||
manifest
|
||||
.versions
|
||||
.retain(|version| !referenced_version_ids.contains(&version.version_id));
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)?;
|
||||
|
||||
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
|
||||
.map_err(|error| format!("素材登记已删除,但项目 revision 未能推进:{error}"))?;
|
||||
@@ -1920,32 +1970,6 @@ pub(crate) fn delete_manifest_asset_at(
|
||||
})
|
||||
}
|
||||
|
||||
/// 从运行期资源使用清单里摘掉指向某素材的引用。
|
||||
///
|
||||
/// 这些引用记录的是"代码曾经引用了它"这一历史事实,素材登记被删除后它们不再有可解析的
|
||||
/// 资源身份,继续留着会产出悬空归属,因此一并清理。
|
||||
fn remove_asset_usage_references(root: &Path, asset_id: &str) -> Result<(), String> {
|
||||
let Some(usage) = read_local_project_asset_usage(root, None)? else {
|
||||
return Ok(());
|
||||
};
|
||||
if !usage
|
||||
.references
|
||||
.iter()
|
||||
.any(|reference| reference.resource_id.as_deref() == Some(asset_id))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let mut references = usage.references.clone();
|
||||
references.retain(|reference| reference.resource_id.as_deref() != Some(asset_id));
|
||||
let diagnostics = usage
|
||||
.diagnostics
|
||||
.iter()
|
||||
.filter(|diagnostic| diagnostic.resource_id.as_deref() != Some(asset_id))
|
||||
.cloned()
|
||||
.collect();
|
||||
write_local_project_asset_usage_at(root, &usage, references, diagnostics)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -848,10 +848,19 @@ pub(crate) fn update_local_project_resource_canvas_layout(
|
||||
)
|
||||
}
|
||||
|
||||
/// 删除一个从未被真实运行槽位绑定过的素材登记。
|
||||
/// 读取引用了某个素材的项目版本,供删除弹窗提示「被哪些版本使用」。
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_asset_references(
|
||||
input: ReadLocalProjectAssetReferencesInput,
|
||||
) -> Result<ReadLocalProjectAssetReferencesResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.list")?;
|
||||
read_manifest_asset_references_at(root, &input.asset_id)
|
||||
}
|
||||
|
||||
/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。
|
||||
///
|
||||
/// 素材不可变:只摘掉 manifest 登记,磁盘文件保留。被任意版本的真实运行槽位绑定过时拒绝,
|
||||
/// 错误里给出版本与槽位。
|
||||
/// 素材不可变:只摘掉 manifest 登记,磁盘文件保留。
|
||||
#[tauri::command]
|
||||
pub(crate) fn delete_local_project_asset(
|
||||
input: DeleteLocalProjectAssetInput,
|
||||
@@ -863,6 +872,7 @@ pub(crate) fn delete_local_project_asset(
|
||||
&input.expected_project_id,
|
||||
input.expected_project_revision,
|
||||
&input.asset_id,
|
||||
input.delete_referenced_versions,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2686,6 +2686,7 @@ fn main() {
|
||||
read_local_project_resource_graph,
|
||||
update_local_project_resource_canvas_layout,
|
||||
delete_local_project_asset,
|
||||
read_local_project_asset_references,
|
||||
get_local_game_project_revision,
|
||||
get_local_game_manifest,
|
||||
download_agc_update,
|
||||
|
||||
@@ -4,7 +4,6 @@ use similar::TextDiff;
|
||||
use std::io::{Seek, SeekFrom};
|
||||
|
||||
mod agent_db;
|
||||
mod asset_usage;
|
||||
mod checkpoint;
|
||||
mod conversation;
|
||||
mod export;
|
||||
@@ -18,7 +17,6 @@ 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::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -720,47 +720,19 @@ pub(crate) fn read_existing_manifest_for_project(
|
||||
|
||||
/// 构造正式版本的资源绑定。
|
||||
///
|
||||
/// 绑定分两部分,顺序固定:
|
||||
///
|
||||
/// 1. **运行槽位绑定**:来自运行期资源使用清单的真实 `slotId → resourceId`。这是替换与
|
||||
/// 点选需要的语义——同一个资源在不同运行位置可以被单独替换。
|
||||
/// 2. **恒等绑定**:`asset:{id} → id`,与历史版本保持一致的兜底,保证未被运行槽位覆盖的
|
||||
/// 资源仍然有绑定,历史版本与旧校验语义不受影响。
|
||||
///
|
||||
/// 槽位绑定优先:同一资源若已被某个槽位绑定,不再为它补恒等绑定,避免同一资源在版本里
|
||||
/// 出现两份互相冲突的语义。
|
||||
/// 绑定即资源记录:`slotId` 恒为恒等映射 `asset:{assetId}`,`resourceId` 指向 manifest 资产 ID,
|
||||
/// 顺序与 `manifest.assets` 一致。要更换某个版本使用的资源,就改这里写进该版本的绑定。
|
||||
pub(crate) fn game_iteration_resource_bindings(
|
||||
root: &Path,
|
||||
manifest: &GameCreationAppManifest,
|
||||
) -> Vec<GameIterationVersionResourceBinding> {
|
||||
let slot_bindings = read_local_project_asset_usage(root, Some(&manifest.project_id))
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|usage| usage.slot_bindings())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut bound_resource_ids: Vec<String> = Vec::new();
|
||||
let mut bindings: Vec<GameIterationVersionResourceBinding> = Vec::new();
|
||||
for (slot_id, resource_id) in slot_bindings {
|
||||
if bound_resource_ids.iter().any(|id| id == &resource_id) {
|
||||
continue;
|
||||
}
|
||||
bound_resource_ids.push(resource_id.clone());
|
||||
bindings.push(GameIterationVersionResourceBinding {
|
||||
slot_id,
|
||||
resource_id,
|
||||
});
|
||||
}
|
||||
for asset in &manifest.assets {
|
||||
if bound_resource_ids.iter().any(|id| id == &asset.id) {
|
||||
continue;
|
||||
}
|
||||
bindings.push(GameIterationVersionResourceBinding {
|
||||
manifest
|
||||
.assets
|
||||
.iter()
|
||||
.map(|asset| GameIterationVersionResourceBinding {
|
||||
slot_id: format!("asset:{}", asset.id),
|
||||
resource_id: asset.id.clone(),
|
||||
});
|
||||
}
|
||||
bindings
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Registers the first formally playable project version after the current
|
||||
@@ -778,7 +750,7 @@ pub(crate) fn ensure_initial_game_iteration_version_at(
|
||||
if !manifest.versions.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let resource_bindings = game_iteration_resource_bindings(root, &manifest);
|
||||
let resource_bindings = game_iteration_resource_bindings(&manifest);
|
||||
manifest.versions.push(GameIterationVersion {
|
||||
version_id: format!("initial-{project_revision}"),
|
||||
parent_version_id: None,
|
||||
@@ -817,7 +789,7 @@ pub(crate) fn append_agent_game_iteration_version_at(
|
||||
return Err("Agent 项目版本必须绑定大于 0 的项目 revision".to_string());
|
||||
}
|
||||
mutate_manifest_at(root, |manifest| {
|
||||
let resource_bindings = game_iteration_resource_bindings(root, manifest);
|
||||
let resource_bindings = game_iteration_resource_bindings(manifest);
|
||||
if let Some(existing) = manifest
|
||||
.versions
|
||||
.iter()
|
||||
@@ -1362,6 +1334,19 @@ pub(crate) fn read_or_create_manifest(
|
||||
pub(crate) fn mutate_manifest_at<T>(
|
||||
root: &Path,
|
||||
mutate: impl FnOnce(&mut GameCreationAppManifest) -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
mutate_manifest_at_allowing_version_removals(root, &no_version_removals, mutate)
|
||||
}
|
||||
|
||||
/// 同 [`mutate_manifest_at`],但允许显式放行的既有项目版本从版本数组里消失。
|
||||
///
|
||||
/// `allowed_version_removals` 按**写入前的 manifest** 求值,只有它返回的版本 ID 才允许被删除;
|
||||
/// 其余既有版本仍必须原样、原顺序保留,新增版本只能追加在末尾。这是"项目版本记录写入后不可
|
||||
/// 修改、删除或重排"校验唯一被放开的入口。
|
||||
pub(crate) fn mutate_manifest_at_allowing_version_removals<T>(
|
||||
root: &Path,
|
||||
allowed_version_removals: &dyn Fn(&GameCreationAppManifest) -> Vec<String>,
|
||||
mutate: impl FnOnce(&mut GameCreationAppManifest) -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
if root.as_os_str().is_empty() {
|
||||
return Err("项目目录不能为空".to_string());
|
||||
@@ -1377,10 +1362,37 @@ pub(crate) fn mutate_manifest_at<T>(
|
||||
let _write_lock = acquire_manifest_write_lock(&manifest_path)?;
|
||||
let (_, mut manifest) = read_or_create_manifest(root)?;
|
||||
let result = mutate(&mut manifest)?;
|
||||
write_manifest_locked(&manifest_path, &manifest)?;
|
||||
write_manifest_locked(&manifest_path, &manifest, allowed_version_removals)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn no_version_removals(_manifest: &GameCreationAppManifest) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// 项目版本数组的写入边界:既有版本只允许按显式放行的 ID 删除,其余必须原样保留且只能追加。
|
||||
fn validate_version_records_are_append_only(
|
||||
existing: &[GameIterationVersion],
|
||||
candidate: &[GameIterationVersion],
|
||||
allowed_version_removals: &[String],
|
||||
) -> Result<(), String> {
|
||||
let violation = || "项目版本记录写入后不可修改、删除或重排".to_string();
|
||||
let mut candidate_versions = candidate.iter();
|
||||
for existing_version in existing
|
||||
.iter()
|
||||
.filter(|version| !allowed_version_removals.contains(&version.version_id))
|
||||
{
|
||||
if candidate_versions.next() != Some(existing_version) {
|
||||
return Err(violation());
|
||||
}
|
||||
}
|
||||
// 放行删除的版本只能真的消失:不得出现在追加段里被改写成"删除后重排"。
|
||||
if candidate_versions.any(|version| allowed_version_removals.contains(&version.version_id)) {
|
||||
return Err(violation());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn manifest_backup_path(path: &Path) -> PathBuf {
|
||||
path.with_file_name(format!(
|
||||
".{}.previous",
|
||||
@@ -1563,12 +1575,13 @@ pub(crate) fn write_manifest(
|
||||
path: &Path,
|
||||
manifest: &GameCreationAppManifest,
|
||||
) -> Result<(), String> {
|
||||
write_manifest_with_lock_hook(path, manifest, || {})
|
||||
write_manifest_with_lock_hook(path, manifest, &no_version_removals, || {})
|
||||
}
|
||||
|
||||
fn write_manifest_with_lock_hook<F>(
|
||||
path: &Path,
|
||||
manifest: &GameCreationAppManifest,
|
||||
allowed_version_removals: &dyn Fn(&GameCreationAppManifest) -> Vec<String>,
|
||||
after_lock: F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
@@ -1582,25 +1595,25 @@ where
|
||||
}
|
||||
let _write_lock = acquire_manifest_write_lock(path)?;
|
||||
after_lock();
|
||||
write_manifest_locked(path, manifest)
|
||||
write_manifest_locked(path, manifest, allowed_version_removals)
|
||||
}
|
||||
|
||||
fn write_manifest_locked(path: &Path, manifest: &GameCreationAppManifest) -> Result<(), String> {
|
||||
fn write_manifest_locked(
|
||||
path: &Path,
|
||||
manifest: &GameCreationAppManifest,
|
||||
allowed_version_removals: &dyn Fn(&GameCreationAppManifest) -> Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
validate_game_iteration_versions(&manifest.versions)
|
||||
.map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?;
|
||||
let payload = serde_json::to_string_pretty(manifest)
|
||||
.map_err(|error| format!("序列化 manifest 失败:{error}"))?;
|
||||
if manifest_storage_exists(path)? {
|
||||
let existing = read_manifest(path)?;
|
||||
if existing.versions.len() > manifest.versions.len()
|
||||
|| existing
|
||||
.versions
|
||||
.iter()
|
||||
.zip(&manifest.versions)
|
||||
.any(|(existing, candidate)| existing != candidate)
|
||||
{
|
||||
return Err("项目版本记录写入后不可修改、删除或重排".to_string());
|
||||
}
|
||||
validate_version_records_are_append_only(
|
||||
&existing.versions,
|
||||
&manifest.versions,
|
||||
&allowed_version_removals(&existing),
|
||||
)?;
|
||||
}
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
|
||||
|
||||
@@ -287,7 +287,7 @@ fn concurrent_manifest_write_cannot_overwrite_an_installed_version_with_a_stale_
|
||||
let (release_newer_tx, release_newer_rx) = mpsc::channel();
|
||||
let newer_path = manifest_path.clone();
|
||||
let newer_writer = std::thread::spawn(move || {
|
||||
write_manifest_with_lock_hook(&newer_path, &newer_manifest, || {
|
||||
write_manifest_with_lock_hook(&newer_path, &newer_manifest, &no_version_removals, || {
|
||||
newer_locked_tx
|
||||
.send(())
|
||||
.expect("signal newer lock acquired");
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
use super::*;
|
||||
|
||||
fn asset_delete_project_fixture() -> PathBuf {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "素材删除口径测试").expect("init project");
|
||||
root
|
||||
}
|
||||
|
||||
fn write_asset_delete_file(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 delete fixture parent");
|
||||
}
|
||||
fs::write(&path, contents).expect("write asset delete fixture asset");
|
||||
}
|
||||
|
||||
fn generated_asset_source(task_id: &str) -> GameCreationAppAssetSource {
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
canvas_project_id: None,
|
||||
resource_id: None,
|
||||
asset_object_id: None,
|
||||
task_id: Some(task_id.to_string()),
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: None,
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 登记一个素材并返回它的 manifest 资产 ID。
|
||||
fn register_asset_delete_fixture_asset(root: &Path, relative_path: &str, task_id: &str) -> String {
|
||||
write_asset_delete_file(root, relative_path, relative_path.as_bytes());
|
||||
register_local_asset_at(
|
||||
root,
|
||||
relative_path,
|
||||
"character",
|
||||
"image/png",
|
||||
"generated",
|
||||
generated_asset_source(task_id),
|
||||
)
|
||||
.expect("register asset delete fixture asset");
|
||||
read_manifest_for_project(root)
|
||||
.expect("read manifest after registration")
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.local_path == relative_path)
|
||||
.expect("asset delete fixture asset registered")
|
||||
.id
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn project_revision(root: &Path) -> u64 {
|
||||
read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read project revision before asset delete")
|
||||
.revision
|
||||
}
|
||||
|
||||
/// 删除走 CAS:调用方拿当前 revision 发起,模拟前端的正常调用。
|
||||
fn delete_asset_at_current_revision(
|
||||
root: &Path,
|
||||
asset_id: &str,
|
||||
delete_referenced_versions: bool,
|
||||
) -> Result<DeleteLocalProjectAssetResult, String> {
|
||||
delete_manifest_asset_at(
|
||||
root,
|
||||
"project-1",
|
||||
project_revision(root),
|
||||
asset_id,
|
||||
delete_referenced_versions,
|
||||
)
|
||||
}
|
||||
|
||||
/// 让当前 revision 成为一个正式版本,绑定当时 manifest 里的全部素材。
|
||||
fn register_initial_version(root: &Path) {
|
||||
let revision =
|
||||
advance_agent_runtime_project_revision_locked(root).expect("advance project revision");
|
||||
assert!(
|
||||
ensure_initial_game_iteration_version_at(root, revision).expect("create initial version"),
|
||||
"initial version should be appended"
|
||||
);
|
||||
}
|
||||
|
||||
/// 推进 durable revision 后追加一个 Agent 正式版本。
|
||||
fn register_agent_version(root: &Path) {
|
||||
let revision =
|
||||
advance_agent_runtime_project_revision_locked(root).expect("advance project revision");
|
||||
assert!(
|
||||
append_agent_game_iteration_version_at(root, revision).expect("append agent version"),
|
||||
"agent version should be appended"
|
||||
);
|
||||
}
|
||||
|
||||
fn version_references_asset(version: &GameIterationVersion, asset_id: &str) -> bool {
|
||||
version
|
||||
.resource_bindings
|
||||
.iter()
|
||||
.any(|binding| binding.resource_id == asset_id)
|
||||
}
|
||||
|
||||
/// 恒等绑定就是资源记录:每个已登记素材一条 `asset:{assetId} → assetId`,顺序与资产表一致。
|
||||
#[test]
|
||||
fn game_iteration_bindings_are_identity_records_for_every_registered_asset() {
|
||||
let root = asset_delete_project_fixture();
|
||||
let first = register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-asset-plan");
|
||||
let second = register_asset_delete_fixture_asset(&root, "assets/scene.png", "art-scene-plan");
|
||||
let manifest = read_manifest_for_project(&root).expect("read manifest with two assets");
|
||||
|
||||
let bindings = game_iteration_resource_bindings(&manifest);
|
||||
let pairs: Vec<(String, String)> = bindings
|
||||
.iter()
|
||||
.map(|binding| (binding.slot_id.clone(), binding.resource_id.clone()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
pairs,
|
||||
vec![
|
||||
(format!("asset:{first}"), first.clone()),
|
||||
(format!("asset:{second}"), second.clone()),
|
||||
]
|
||||
);
|
||||
|
||||
register_initial_version(&root);
|
||||
let versioned = read_manifest_for_project(&root).expect("read manifest with initial version");
|
||||
assert_eq!(versioned.versions[0].resource_bindings, bindings);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 未被任何版本引用的素材:直接删除登记,磁盘文件保留,revision 推进一格。
|
||||
#[test]
|
||||
fn asset_delete_removes_unreferenced_asset_registration() {
|
||||
let root = asset_delete_project_fixture();
|
||||
let asset_id = register_asset_delete_fixture_asset(&root, "assets/unused.png", "art-unused");
|
||||
let revision_before = project_revision(&root);
|
||||
|
||||
let result = delete_asset_at_current_revision(&root, &asset_id, false)
|
||||
.expect("delete unreferenced asset");
|
||||
|
||||
assert_eq!(result.asset_id, asset_id);
|
||||
assert_eq!(result.local_path, "assets/unused.png");
|
||||
assert!(result.file_retained, "素材不可变,磁盘文件必须保留");
|
||||
assert_eq!(
|
||||
result.committed_project_revision,
|
||||
revision_before + 1,
|
||||
"删除成功后项目 revision 必须推进一格"
|
||||
);
|
||||
assert!(
|
||||
root.join("assets/unused.png").is_file(),
|
||||
"删除登记不得删除磁盘文件"
|
||||
);
|
||||
let manifest = read_manifest_for_project(&root).expect("read manifest after delete");
|
||||
assert!(
|
||||
manifest.assets.iter().all(|asset| asset.id != asset_id),
|
||||
"素材登记应已摘掉"
|
||||
);
|
||||
assert!(manifest.versions.is_empty());
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 有引用但不勾选:只删素材,引用它的版本原样保留悬空绑定,不报错、不做二次拦截。
|
||||
#[test]
|
||||
fn asset_delete_keeps_referencing_versions_when_not_confirmed() {
|
||||
let root = asset_delete_project_fixture();
|
||||
let asset_id = register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-asset-plan");
|
||||
register_initial_version(&root);
|
||||
register_agent_version(&root);
|
||||
|
||||
let result = delete_asset_at_current_revision(&root, &asset_id, false)
|
||||
.expect("delete referenced asset without deleting versions");
|
||||
|
||||
assert!(root.join(&result.local_path).is_file());
|
||||
let manifest = read_manifest_for_project(&root).expect("read manifest after delete");
|
||||
assert!(
|
||||
manifest.assets.iter().all(|asset| asset.id != asset_id),
|
||||
"素材登记应已摘掉"
|
||||
);
|
||||
assert_eq!(manifest.versions.len(), 2, "未勾选时两个版本都必须原样保留");
|
||||
assert!(
|
||||
manifest
|
||||
.versions
|
||||
.iter()
|
||||
.all(|version| version_references_asset(version, &asset_id)),
|
||||
"版本保留指向已删素材的悬空绑定"
|
||||
);
|
||||
|
||||
// 悬空绑定不阻塞后续版本追加。
|
||||
register_agent_version(&root);
|
||||
assert_eq!(
|
||||
read_manifest_for_project(&root)
|
||||
.expect("read manifest after later version")
|
||||
.versions
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 有引用且勾选:素材与所有引用它的版本在同一次 manifest 写入里一起删除。
|
||||
#[test]
|
||||
fn asset_delete_removes_referencing_versions_when_confirmed() {
|
||||
let root = asset_delete_project_fixture();
|
||||
let kept_asset_id =
|
||||
register_asset_delete_fixture_asset(&root, "assets/base.png", "art-base-plan");
|
||||
register_initial_version(&root);
|
||||
let removed_asset_id =
|
||||
register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-asset-plan");
|
||||
register_agent_version(&root);
|
||||
register_agent_version(&root);
|
||||
|
||||
let before = read_manifest_for_project(&root).expect("read manifest before delete");
|
||||
assert_eq!(before.versions.len(), 3);
|
||||
assert!(
|
||||
!version_references_asset(&before.versions[0], &removed_asset_id),
|
||||
"首个版本早于该素材,不应引用它"
|
||||
);
|
||||
assert_eq!(
|
||||
before
|
||||
.versions
|
||||
.iter()
|
||||
.filter(|version| version_references_asset(version, &removed_asset_id))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
|
||||
let result = delete_asset_at_current_revision(&root, &removed_asset_id, true)
|
||||
.expect("delete referencing asset together with its versions");
|
||||
|
||||
assert!(root.join(&result.local_path).is_file());
|
||||
let manifest = read_manifest_for_project(&root).expect("read manifest after delete");
|
||||
assert!(manifest.assets.iter().all(|asset| asset.id != removed_asset_id));
|
||||
assert!(
|
||||
manifest.assets.iter().any(|asset| asset.id == kept_asset_id),
|
||||
"未被删除的素材登记必须保留"
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.versions.len(),
|
||||
1,
|
||||
"引用被删素材的版本必须一并删除:{:?}",
|
||||
manifest
|
||||
.versions
|
||||
.iter()
|
||||
.map(|version| version.version_id.clone())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
manifest
|
||||
.versions
|
||||
.iter()
|
||||
.all(|version| !version_references_asset(version, &removed_asset_id)),
|
||||
"剩余版本不得再引用被删素材"
|
||||
);
|
||||
assert_eq!(manifest.versions[0].version_id, before.versions[0].version_id);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 只读投影:列出引用了该素材的版本,顺序与版本数组一致。
|
||||
#[test]
|
||||
fn asset_reference_projection_lists_only_versions_using_the_asset() {
|
||||
let root = asset_delete_project_fixture();
|
||||
let early_asset_id = register_asset_delete_fixture_asset(&root, "assets/base.png", "art-base");
|
||||
register_initial_version(&root);
|
||||
let late_asset_id = register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-hero");
|
||||
register_agent_version(&root);
|
||||
let manifest = read_manifest_for_project(&root).expect("read manifest");
|
||||
|
||||
let late_references =
|
||||
read_manifest_asset_references_at(&root, &late_asset_id).expect("read late asset references");
|
||||
assert_eq!(late_references.asset_id, late_asset_id);
|
||||
assert_eq!(late_references.versions.len(), 1);
|
||||
assert_eq!(
|
||||
late_references.versions[0].version_id,
|
||||
manifest.versions[1].version_id
|
||||
);
|
||||
assert_eq!(
|
||||
late_references.versions[0].project_revision,
|
||||
manifest.versions[1].project_revision
|
||||
);
|
||||
assert_eq!(
|
||||
late_references.versions[0].created_at,
|
||||
manifest.versions[1].created_at
|
||||
);
|
||||
|
||||
let early_references = read_manifest_asset_references_at(&root, &early_asset_id)
|
||||
.expect("read early asset references");
|
||||
assert_eq!(
|
||||
early_references
|
||||
.versions
|
||||
.iter()
|
||||
.map(|version| version.version_id.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
manifest
|
||||
.versions
|
||||
.iter()
|
||||
.map(|version| version.version_id.clone())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let absent_references =
|
||||
read_manifest_asset_references_at(&root, "absent-asset").expect("read absent references");
|
||||
assert!(absent_references.versions.is_empty());
|
||||
|
||||
let error = read_manifest_asset_references_at(&root, " ")
|
||||
.expect_err("blank asset id must be rejected");
|
||||
assert!(error.contains("assetId"), "unexpected error: {error}");
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 版本数组"只允许追加"校验只为显式确认的连带删除放开,其它写入路径仍然必须拒绝。
|
||||
#[test]
|
||||
fn version_removal_stays_rejected_outside_the_confirmed_asset_delete() {
|
||||
let root = asset_delete_project_fixture();
|
||||
register_asset_delete_fixture_asset(&root, "assets/base.png", "art-base");
|
||||
register_initial_version(&root);
|
||||
register_agent_version(&root);
|
||||
let installed = fs::read(root.join(".agent/manifest.json")).expect("read installed manifest");
|
||||
|
||||
let error = mutate_manifest_at(&root, |manifest| {
|
||||
manifest.versions.pop();
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("a plain manifest mutation must not delete versions");
|
||||
assert!(error.contains("不可修改、删除或重排"), "unexpected error: {error}");
|
||||
|
||||
let reordered = mutate_manifest_at(&root, |manifest| {
|
||||
manifest.versions.swap(0, 1);
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("a plain manifest mutation must not reorder versions");
|
||||
assert!(!reordered.is_empty(), "reorder must surface a rejection");
|
||||
|
||||
assert_eq!(
|
||||
fs::read(root.join(".agent/manifest.json")).expect("read manifest bytes after rejection"),
|
||||
installed,
|
||||
"被拒绝的写入不得改动 manifest"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 删除同样走 CAS:陈旧 revision、跨项目身份与不存在的素材都被拒绝,manifest 与 revision 不变。
|
||||
#[test]
|
||||
fn asset_delete_enforces_revision_and_identity_cas() {
|
||||
let root = asset_delete_project_fixture();
|
||||
let asset_id = register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-asset-plan");
|
||||
let revision_before = project_revision(&root);
|
||||
|
||||
let revision_error = delete_manifest_asset_at(
|
||||
&root,
|
||||
"project-1",
|
||||
revision_before + 1,
|
||||
&asset_id,
|
||||
true,
|
||||
)
|
||||
.expect_err("stale revision must be rejected");
|
||||
assert_eq!(revision_error, "project-revision-conflict");
|
||||
|
||||
let identity_error =
|
||||
delete_manifest_asset_at(&root, "project-2", revision_before, &asset_id, true)
|
||||
.expect_err("cross-project identity must be rejected");
|
||||
assert_eq!(identity_error, "project-identity-conflict");
|
||||
|
||||
let missing_error = delete_manifest_asset_at(&root, "project-1", revision_before, "absent", true)
|
||||
.expect_err("missing asset must be rejected");
|
||||
assert!(
|
||||
missing_error.contains("项目资源不存在"),
|
||||
"unexpected error: {missing_error}"
|
||||
);
|
||||
|
||||
let manifest = read_manifest_for_project(&root).expect("read manifest after rejected deletes");
|
||||
assert!(
|
||||
manifest.assets.iter().any(|asset| asset.id == asset_id),
|
||||
"失败的删除不得改动 manifest"
|
||||
);
|
||||
assert_eq!(
|
||||
project_revision(&root),
|
||||
revision_before,
|
||||
"失败的删除不得推进 revision"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6064,8 +6064,8 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
mod asset_delete;
|
||||
mod collaboration;
|
||||
mod asset_usage;
|
||||
mod command_runtime;
|
||||
pub(crate) mod configuration;
|
||||
mod goal;
|
||||
|
||||
Reference in New Issue
Block a user