收敛 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:
2026-09-10 14:22:18 +08:00
parent 912254afa0
commit 824934df41
10 changed files with 570 additions and 1441 deletions
@@ -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;