From 5fff7a134ca4d91b12cb4059f60e67e99ac84770 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 21:18:55 +0800 Subject: [PATCH] =?UTF-8?q?=E2=91=A3=20=E8=B5=84=E6=BA=90=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E5=86=99=E5=85=A5=E8=A1=A5=E4=B8=80=E6=9D=A1=20agent.?= =?UTF-8?q?db=20=E5=AE=A1=E8=AE=A1=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update_manifest_asset_classification_at 在 manifest 写成功后追加 recordType=asset.classification.update 的审计,复用既有 append_agent_db_record 惯例(照 assets.rs 的 asset.register / asset.update:manifest 写成功后追加) - 字段:assetId(沿用 assets.rs 的 assetId)、expectedProjectRevision(本次写入实际校验的 CAS 基准 revision,沿用分类输入契约里的同名参数)、previousCategory / previousTags(变更前值)、category / tags(变更后值) - 不记 localPath / kind / mediaType / source:本记录针对的是已有 assetId 的分类变更,这些字段并未改变,且能从 manifest 或既有 asset.register 记录追到,多记一份会在改名后产生互相矛盾的审计 - 不用 projectRevision 这个名字:既有 agent.runtime.action_receipt 里 projectRevision 的语义是「动作完成后的 revision」,本条审计追加在 revision 推进之前,沿用同名会指代不一致;审计是持久化数据,故在新记录类型里另起不冲突的名字,不动既有记录格式约定 - 位置放在推进 revision 之前是刻意的:分类已经真实落盘,审计不能因为紧随其后的 revision 推进失败而缺失,否则「改过但查不到」正是这条缺陷;推进失败仍然照旧报错 - 审计追加失败按既有惯例映射为可见错误「资源分类已写入,但审计记录失败:{error}」,不静默吞掉 - project/manifest/classification_tests.rs 补 3 条断言:成功写入恰好一条且前后值正确(含第二次写入的 previous 必须取第一次的落盘值)、四类被拒写入不产生任何审计、审计写失败时错误可见且不留假审计(同时确认分类本身已落盘、不回滚) --- .../src-tauri/src/project/manifest.rs | 23 ++- .../project/manifest/classification_tests.rs | 182 ++++++++++++++++++ 2 files changed, 203 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index fbd020f82..bc72a7fad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1146,7 +1146,7 @@ pub(crate) fn update_manifest_asset_classification_at( return Err("project-revision-conflict".to_string()); } - let asset = mutate_manifest_at(root, |manifest| { + let (asset, previous_category, previous_tags) = mutate_manifest_at(root, |manifest| { let Some(asset) = manifest .assets .iter_mut() @@ -1154,10 +1154,29 @@ pub(crate) fn update_manifest_asset_classification_at( else { return Err(format!("项目资源不存在:{asset_id}")); }; + let previous_category = asset.category; + let previous_tags = asset.tags.clone(); asset.category = category; asset.tags = tags; - Ok(asset.clone()) + Ok((asset.clone(), previous_category, previous_tags)) })?; + // 审计紧跟在「manifest 已落盘」之后、`advance_agent_runtime_project_revision_locked` 之前: + // 与 `assets.rs` 的 `asset.register` / `asset.update` 同一位置约定(manifest 写成功后追加)。 + // 放在推进 revision 之前是刻意的——分类已经真实落盘,审计不能因为紧随其后的 revision 推进 + // 失败而缺失,否则「改过但查不到」正是这条缺陷要修的东西;推进失败仍然照旧报错(见下方 map_err)。 + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "asset.classification.update", + "assetId": asset.id, + "expectedProjectRevision": expected_project_revision, + "previousCategory": previous_category, + "previousTags": previous_tags, + "category": asset.category, + "tags": asset.tags, + }), + ) + .map_err(|error| format!("资源分类已写入,但审计记录失败:{error}"))?; let committed_project_revision = advance_agent_runtime_project_revision_locked(root) .map_err(|error| format!("资源分类已写入,但项目 revision 未能推进:{error}"))?; Ok(UpdateLocalProjectResourceClassificationResult { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs index dfda43e64..2c62a6b5e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs @@ -277,3 +277,185 @@ fn asset_classification_input_rejects_unknown_fields() { ); assert!(input.is_err()); } + +/// 分类写入的 `recordType`。取 `asset.*` 命名族,与 `assets.rs` 的 `asset.register` / +/// `asset.update` 同族;`.update` 与「只改分类与标签、不新增资源登记」的语义一致。 +const ASSET_CLASSIFICATION_AUDIT_RECORD_TYPE: &str = "asset.classification.update"; + +fn classification_audit_records(root: &Path) -> Vec { + let (records, truncated) = + read_agent_db_records_bounded(root, 4 * 1024 * 1024).expect("read agent db records"); + assert!(!truncated, "分类测试的 agent.db 不应触达尾窗上限"); + records + .into_iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some(ASSET_CLASSIFICATION_AUDIT_RECORD_TYPE) + }) + .collect() +} + +/// 分类写入必须留下一条 agent.db 审计,且字段能回答「把哪个资源从哪个分类改成了什么」。 +#[test] +fn asset_classification_update_appends_one_agent_db_audit_record() { + let (root, asset_id) = project_with_two_assets("audit-success"); + let project_id = read_existing_manifest_for_project(&root) + .expect("read manifest") + .project_id; + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before") + .revision; + assert!( + classification_audit_records(&root).is_empty(), + "初始化阶段不应存在分类审计" + ); + + update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &asset_id, + "scene", + vec!["主舞台".to_string()], + ) + .expect("update classification"); + + let records = classification_audit_records(&root); + assert_eq!(records.len(), 1, "{records:?}"); + let record = &records[0]; + assert_eq!(record["assetId"], serde_json::json!(asset_id)); + assert_eq!(record["previousCategory"], serde_json::json!("character")); + assert_eq!(record["previousTags"], serde_json::json!([])); + assert_eq!(record["category"], serde_json::json!("scene")); + assert_eq!(record["tags"], serde_json::json!(["主舞台"])); + assert_eq!( + record["expectedProjectRevision"], + serde_json::json!(revision_before) + ); + // 信封字段由 `append_agent_db_record` 统一追加:新增记录类型不得改动既有信封约定。 + assert_eq!( + record["schemaVersion"], + serde_json::json!(GAME_CREATOR_AGENT_DB_SCHEMA_VERSION) + ); + assert!(record["updatedAt"].as_u64().is_some_and(|value| value > 0)); + + // 第二次写入的「变更前值」必须是第一次写入后的落盘值,证明前后值取自 manifest 而不是硬编码。 + let revision_after_first = revision_before + 1; + update_manifest_asset_classification_at( + &root, + &project_id, + revision_after_first, + &asset_id, + "document", + vec!["说明".to_string()], + ) + .expect("update classification twice"); + + let records = classification_audit_records(&root); + assert_eq!(records.len(), 2, "{records:?}"); + assert_eq!(records[1]["assetId"], serde_json::json!(asset_id)); + assert_eq!(records[1]["previousCategory"], serde_json::json!("scene")); + assert_eq!(records[1]["previousTags"], serde_json::json!(["主舞台"])); + assert_eq!(records[1]["category"], serde_json::json!("document")); + assert_eq!(records[1]["tags"], serde_json::json!(["说明"])); + assert_eq!( + records[1]["expectedProjectRevision"], + serde_json::json!(revision_after_first) + ); + fs::remove_dir_all(root).ok(); +} + +/// 被拒的分类写入不得留下审计:审计是「真的改了什么」的记录,不是「尝试过什么」的记录。 +#[test] +fn asset_classification_rejections_do_not_append_agent_db_audit_records() { + let (root, asset_id) = project_with_two_assets("audit-rejections"); + let project_id = read_existing_manifest_for_project(&root) + .expect("read manifest") + .project_id; + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision") + .revision; + + update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &asset_id, + "future-category", + Vec::new(), + ) + .expect_err("invalid category must fail"); + update_manifest_asset_classification_at( + &root, + &project_id, + revision_before + 1, + &asset_id, + "scene", + Vec::new(), + ) + .expect_err("stale revision must fail"); + update_manifest_asset_classification_at( + &root, + "other-project", + revision_before, + &asset_id, + "scene", + Vec::new(), + ) + .expect_err("foreign project identity must fail"); + update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + "missing-asset", + "scene", + Vec::new(), + ) + .expect_err("missing asset must fail"); + + assert!( + classification_audit_records(&root).is_empty(), + "被拒的分类写入不得产生审计" + ); + fs::remove_dir_all(root).ok(); +} + +/// 审计写失败必须可见:分类已经落盘的既定行为不变(不静默回滚),但命令要报错, +/// 而且不能留下一条假的审计。 +#[test] +fn asset_classification_audit_failure_is_reported_and_never_faked() { + let (root, asset_id) = project_with_two_assets("audit-failure"); + let project_id = read_existing_manifest_for_project(&root) + .expect("read manifest") + .project_id; + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision") + .revision; + fs::write( + root.join(".agent/runtime/test-fail-next-agent-db-record"), + ASSET_CLASSIFICATION_AUDIT_RECORD_TYPE, + ) + .expect("write audit failure injection marker"); + + let error = update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &asset_id, + "scene", + vec!["主舞台".to_string()], + ) + .expect_err("an audit append failure must surface to the caller"); + + assert!(error.contains("审计记录失败"), "unexpected error: {error}"); + assert!(classification_audit_records(&root).is_empty()); + let persisted = read_existing_manifest_for_project(&root).expect("read manifest after failure"); + let asset = persisted + .assets + .iter() + .find(|asset| asset.id == asset_id) + .expect("persisted asset"); + assert_eq!(asset.category, GameCreationAppAssetCategory::Scene); + assert_eq!(asset.tags, vec!["主舞台"]); + fs::remove_dir_all(root).ok(); +}